Bitcoin API

Bitcoin には 2 つの入口があります。

入口 役割
BitcoinApp(@openloop/sdk-core) デバイスとのやり取り。アドレス・拡張公開鍵の取得、メッセージ署名(BIP-137)、PSBT(Partially Signed Bitcoin Transaction)署名
@openloop/sdk-core/bitcoin(サブパス) デバイスに触れない補助。アドレスの導出と検証、UTXO の取得と選択、PSBT の組み立て、署名済み PSBT からの取引の取り出し、ブロードキャスト

サブパスを使うと、送金に必要な処理を SDK だけで組み立てられます。対応するアドレスの型は 3 つです。

型 BtcScript BIP-43 の purpose アドレス
Native SegWit(P2WPKH) 'p2wpkh' 84' bc1q… / tb1q…
Nested SegWit(P2SH-P2WPKH) 'p2sh-p2wpkh' 49' 3… / 2…
Legacy(P2PKH) 'p2pkh' 44' 1… / m… / n…

インポート

import {
  BitcoinApp,
  BTC_MAINNET_PATH,
  BTC_TESTNET_PATH,
  type BtcMessageSignature,
  type InputSigningPath,
} from '@openloop/sdk-core'

import {
  scanAccount,
  selectUtxos,
  buildPsbt,
  extractRawTx,
  broadcastTx,
  BtcError,
  type BtcNetwork,
  type BtcAccount,
} from '@openloop/sdk-core/bitcoin'

署名の検証(verifyBtcMessage / verifyBtcPsbt)は別のサブパス @openloop/sdk-core/verify にあります(署名の検証)。


BitcoinApp

初期化

import { BitcoinApp } from '@openloop/sdk-core'

const btc = new BitcoinApp(transport)

transport は ITransport インターフェースを実装する任意の Transport インスタンスです。

メソッド一覧

メソッド 説明
getAddress(testnet?) BIP-84 の既定パスで bech32 アドレス取得(testnet で mainnet/testnet を切り替え)
getAddressWithPath(path) 任意の BIP32 パスでアドレス取得
getAccountXpub(coinType) アカウントレベル(84'/coin'/0')の公開鍵とチェーンコードの取得
getExtendedPubkey(path, display?) 任意のパスの拡張公開鍵の取得(BtcXpub)
getMasterFingerprint() マスター鍵フィンガープリント(4 バイト hex)の取得
signMessage(message, path) BIP-137 メッセージ署名
signPsbt(psbt) PSBT 署名(バイナリ入出力)
signPsbtHex(psbt) PSBT 署名(hex 入出力)
signPsbtWithPaths(psbt, inputPaths) 入力パス指定付き PSBT 署名

どのメソッドも、実行中は Transport の lock() / unlock() で排他を取ります。

パスの文字列は "84'/0'/0'/0/0" と "m/84'/0'/0'/0/0" のどちらでも渡せます。強化導出の印は ' だけです(h は使えません)。


getAddress

bech32(P2WPKH)アドレスを取得します。

async getAddress(testnet?: boolean): Promise<{
  publicKey: string   // 非圧縮公開鍵 (hex, 65 bytes)
  address: string     // bech32 アドレス ("bc1..." or "tb1...")
  chainCode: string   // チェーンコード (hex, 32 bytes)
}>

パラメータ

名前 型 デフォルト 説明
testnet boolean false true: testnet パス (84'/1'/0'/0/0)

デフォルトパス

ネットワーク パス 定数
Mainnet 84'/0'/0'/0/0 BTC_MAINNET_PATH
Testnet 84'/1'/0'/0/0 BTC_TESTNET_PATH

使用例

// Mainnet
const { address } = await btc.getAddress()
// address: "bc1q..."

// Testnet
const { address: testnetAddress } = await btc.getAddress(true)
// testnetAddress: "tb1q..."

getAddressWithPath

任意の BIP32 パスでアドレスを取得します。マルチアカウントやお釣り用アドレスの取得に使用します。

async getAddressWithPath(path: string): Promise<{
  publicKey: string   // 非圧縮公開鍵 (hex, 65 bytes)
  address: string
  chainCode: string
}>

現行のファームウェアは、パスの purpose(44' / 49' / 84')にかかわらず bech32(P2WPKH)形式のアドレスを返します。ネットワークはパスの 2 段目(coin type)で決まり、1' なら tb1…、それ以外は bc1… です。Legacy(P2PKH)や Nested SegWit(P2SH-P2WPKH)のアドレスが必要なときは、getExtendedPubkey で口座の拡張公開鍵を取り、サブパスの deriveAddresses で導出します。

使用例

// 2番目のアカウントの最初のアドレス
const second = await btc.getAddressWithPath("84'/0'/1'/0/0")

// お釣り用アドレス
const change = await btc.getAddressWithPath("84'/0'/0'/1/0")

// "m/" プレフィックスも可
const sixth = await btc.getAddressWithPath("m/84'/0'/0'/0/5")

getAccountXpub

アカウントレベルの公開鍵とチェーンコード(m/84'/coin'/0')を取得します。パスは固定で、BIP-84 の口座 0 だけが取れます。Legacy(44')・Nested SegWit(49')や口座 1 以降が必要なときは getExtendedPubkey を使います。

async getAccountXpub(coinType: number): Promise<{
  publicKey: string   // 圧縮公開鍵 (hex, 33 bytes)
  chainCode: string   // チェーンコード (hex, 32 bytes)
}>

パラメータ

名前 型 説明
coinType number 0: mainnet, 1: testnet

使用例

const { publicKey, chainCode } = await btc.getAccountXpub(0)  // mainnet

返り値には depth や親のフィンガープリントが含まれないので、サブパスの deriveAddresses には渡せません(BtcXpub が必要です)。デバイスの応答が 65 バイトでないときは Error を投げます。


getExtendedPubkey

任意のパスの拡張公開鍵を取得します(Ledger Bitcoin v2 プロトコルの GET_EXTENDED_PUBKEY。CLA 0xE1 / INS 0x00)。返り値の BtcXpub は、サブパスの deriveAddresses / scanAccount / findAddressPath にそのまま渡せます。

async getExtendedPubkey(path: string, display?: boolean): Promise<BtcXpub>

interface BtcXpub {
  publicKey: string         // 圧縮公開鍵 (hex, 33 bytes)
  chainCode: string         // チェーンコード (hex, 32 bytes)
  parentFingerprint: number // 直前の親のフィンガープリント(master ではない)
  depth: number             // m からの段数
  xpub: string              // 直列化された文字列("xpub…" / "tpub…")
}

パラメータ

名前 型 デフォルト 説明
path string — 例: "m/84'/1'/0'"。10 段まで。"m"(根)も可
display boolean false 現行のデバイスでは画面表示に影響しません

検証

デバイスの答えが、訊いたパスのものであることを SDK が確かめます。次のどれかが食い違うと Error を投げます。

10 段を超えるパスと、数値として読めない段も、デバイスに送信する前に Error で断ります。

対応するファームウェアと返る形

使用例

import { accountPathOf } from '@openloop/sdk-core/bitcoin'

// Native SegWit・口座 0・testnet の拡張公開鍵("m/84'/1'/0'")
const xpub = await btc.getExtendedPubkey(accountPathOf({ script: 'p2wpkh', account: 0 }, 'testnet4'))
console.log(xpub.xpub.slice(0, 4), xpub.depth) // "tpub" 3

▲ 拡張公開鍵は、その口座が将来作るすべてのアドレスを導出できる情報です。受け取った側で外部に漏らさないでください。

SDK は次の関数も公開します(@openloop/sdk-core)。

名前 説明
buildExtendedPubkeyRequest(path, display?) GET_EXTENDED_PUBKEY のデータ部(Uint8Array)を組む純関数
parseExtendedPubkeyResponse(xpub, requestedPath) デバイスの答え(文字列)を読み、上の検証をして BtcXpub を返す純関数
MAX_EXTENDED_PUBKEY_PATH_LEVELS 受け付けるパスの段数の上限(10)

getMasterFingerprint

ウォレットの BIP32 マスター鍵フィンガープリント(hash160(マスター公開鍵)[:4])を取得します。デバイス内部で計算される値と同一で、PSBT の入力 BIP32 派生(master_fingerprint)に設定する値です。

async getMasterFingerprint(): Promise<string>  // 8 文字の小文字 hex(ビッグエンディアン)、例: "631698e0"

用途

通常のウォレット連携では、この値はアカウントインポート時に一度だけ取得し、以後 PSBT を組む際の入力派生に埋め込みます。とくに AirGap / ウォッチオンリー(QR PSBT) 経路では、デバイスは入力をこのフィンガープリント一致で自分のものと判定するため、PSBT の入力に正しい master_fingerprint が無いと「別ウォレット宛」と見なされ署名できません(USB の signPsbtWithPaths のような入力パス注入が無いため)。

内部的には 0 階層(master m)の公開鍵を取得し、hash160 の先頭 4 バイトを返します。これは Sparrow / HWI が行う処理と同一です。

使用例

const fp = await btc.getMasterFingerprint()  // 例: "631698e0"

// PSBT 入力 BIP32 派生に埋め込む(type 0x06):
//   value = fingerprint(4B) ‖ path(4B LE × N)

signMessage

BIP-137 形式でメッセージに署名します。

async signMessage(
  message: string,
  path: string
): Promise<BtcMessageSignature>

パラメータ

名前 型 説明
message string UTF-8 メッセージ
path string BIP32 パス(例: "84'/0'/0'/0/0")

返り値: BtcMessageSignature

フィールド 型 説明
v number BIP-137 のヘッダバイト(下表)。v から基準値を引いた値がリカバリー ID(0〜3)
r string R 値 (32 bytes hex)
s string S 値 (32 bytes hex)
signature string 完全な署名 (65 bytes: V || R || S) の Base64。先頭の 1 バイトは上の v

v は、パスの purpose(1 段目)に合わせて SDK が補正します。どの経路(USB / BLE / WalletConnect など)でも、外部の検証器が受け付けるヘッダになります。

パスの purpose v の範囲 対応するアドレスの型
84' 39〜42 P2WPKH(native SegWit)
49' 35〜38 P2SH-P2WPKH
44' 31〜34 P2PKH(圧縮鍵)
それ以外 35〜38 補正なし

デバイスの応答が 65 バイトでないときは Error を投げます。利用者が拒否したときは ApduError(0x6985)です。

使用例

const sig = await btc.signMessage('Hello, Bitcoin!', BTC_MAINNET_PATH)
console.log(sig.v)         // 39〜42(BTC_MAINNET_PATH は 84')
console.log(sig.signature) // Base64 encoded signature

signPsbt

PSBT(Partially Signed Bitcoin Transaction)に署名します。PSBT 内の BIP32_DERIVATION フィールドからパスを自動検出します。

async signPsbt(psbt: Uint8Array | string): Promise<Uint8Array>

パラメータ

名前 型 説明
psbt Uint8Array \| string PSBT バイナリデータまたは hex 文字列(0x 付きも可)

返り値

署名済み PSBT のバイナリデータ (Uint8Array)。デバイスは署名のあと入力を最終化(FINAL_SCRIPTSIG / FINAL_SCRIPTWITNESS)して返すので、そのまま extractRawTx に渡せます(hex にしてから)。

エラー

投げるもの 条件
ApduError デバイスが拒否した・受け付けなかった(0x6985 は拒否など)
BtcError(code: 'psbt_too_large') 署名済み PSBT が読み戻しの上限(256 かたまり × 250 バイト = 64,000 バイト)を超えた

入力の本数は SDK では制限しません。デバイスの上限(PSBT 全体で 32 入力など)を超えると、デバイスが断って ApduError になります。

BitcoinApp が投げる BtcError は、@openloop/sdk-core/bitcoin から import した BtcError と同じクラスです(0.8.1 以降。instanceof BtcError で見分けられます。0.8.0 以前は別のクラスなので e.name === 'BtcError' と e.code で判定してください)。

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

try {
  await btc.signPsbt('70736274ff...')
} catch (e) {
  if (e instanceof BtcError) {
    console.log(e.code) // 'psbt_too_large'
  }
}

signPsbtHex

hex 文字列での PSBT 署名。入出力ともに hex 文字列です(出力に 0x は付きません)。

async signPsbtHex(psbt: string): Promise<string>

使用例

const signedHex = await btc.signPsbtHex('70736274ff...')

signPsbtWithPaths

各入力の BIP32 パスを明示的に指定して PSBT に署名します。PSBT に BIP32_DERIVATION フィールドがない場合や、特定の入力のみ署名したい場合に使用します。サブパスの buildPsbt が返す paths は、そのままこの第 2 引数に渡せます。

async signPsbtWithPaths(
  psbt: string,
  inputPaths: InputSigningPath[]
): Promise<string>

パラメータ

名前 型 説明
psbt string PSBT hex 文字列(0x 付きも可)
inputPaths InputSigningPath[] 入力ごとのパス指定

返り値は署名済み PSBT の hex 文字列(0x なし)。最終化済みである点は signPsbt と同じです。

パスを付けた署名は、現行のデバイスでは 10 入力までです。超えるとデバイスが断り ApduError になります。

InputSigningPath

フィールド 型 説明
index number PSBT の入力インデックス
path string BIP32 パス(例: "84'/0'/0'/0/5")

使用例

const signedHex = await btc.signPsbtWithPaths('70736274ff...', [
  { index: 0, path: "84'/0'/0'/0/0" },
  { index: 1, path: "84'/0'/0'/0/3" },
  { index: 2, path: "84'/0'/0'/1/0" }, // お釣り
])

@openloop/sdk-core/bitcoin

デバイスに触れずに、送金の前後を組み立てる関数群です。SDK は UI も既定のサーバーも持たないので、次の 3 つを守っています。

以降の断片は、次の変数が定義済みとして書いています。

import { BitcoinApp, type ITransport } from '@openloop/sdk-core'
import {
  accountPathOf,
  createAddressCache,
  scanAccount,
  type BtcNetwork,
  type BtcAccount,
} from '@openloop/sdk-core/bitcoin'

declare const transport: ITransport // 接続済みの Transport

const btc = new BitcoinApp(transport)

// 端点は呼ぶ側が決めます(SDK は既定値を持ちません)
const net: BtcNetwork = {
  id: 'testnet4',
  esplora: ['https://mempool.space/testnet4/api'],
  feeEndpoints: ['https://mempool.space/testnet4/api'],
  timeoutMs: 15_000,
}
const acct: BtcAccount = { script: 'p2wpkh', account: 0 }

// 口座の拡張公開鍵はデバイスから 1 回だけ取る
const xpub = await btc.getExtendedPubkey(accountPathOf(acct, net.id))

// 口座を走査して UTXO と次のお釣りのアドレスを得る
const cache = createAddressCache()
const { utxos, nextChange } = await scanAccount(xpub, acct, net, { cache })

型

type BtcNetworkId = 'mainnet' | 'testnet3' | 'testnet4'
type BtcScript = 'p2pkh' | 'p2sh-p2wpkh' | 'p2wpkh'

interface BtcNetwork {
  id: BtcNetworkId
  esplora: string[]       // Esplora 互換 API のベース URL。最良のものから順に試す
  feeEndpoints: string[]  // `/v1/fees/recommended` を返す端点(mempool.space 互換)
  timeoutMs: number       // 1 回の呼び出しの時間切れ(ミリ秒)。本文の読み取りも含む
}

interface BtcAccount {
  script: BtcScript       // purpose(44' / 49' / 84')を決める
  account: number         // 口座の番号(強化)
}

interface BtcUtxo {
  txid: string
  vout: number
  value: number           // sats
  address: string
  path: string            // 例: "m/84'/1'/0'/0/3"
  publicKey: string       // 圧縮公開鍵 (hex, 33 bytes)
  confirmed: boolean      // 1 確認未満は false
  blockHeight?: number
}

type BtcAddressKind = 'p2pkh' | 'p2sh' | 'p2wpkh' | 'p2wsh' | 'p2tr'

関数一覧

関数 分類 説明
pathOf(acct, net, change, index) パス 末端までのパス("m/84'/1'/0'/1/3")
accountPathOf(acct, net) パス 口座までのパス("m/84'/1'/0'")
publicKeyToAddress(publicKey, script, net) アドレス 公開鍵からその型のアドレスを作る
deriveAddresses(xpub, acct, net, change, from, count) アドレス 口座の拡張公開鍵から連続したアドレスを導出
validateAddress(address, net) アドレス 宛先のアドレスを検める(3 方式・網の突き合わせ)
addressToScriptPubKey(address, net) アドレス アドレスから scriptPubKey を作る
encodeSegwitAddress(hrp, version, program) / decodeSegwitAddress(address) アドレス bech32 / bech32m の符号化と検査つきの展開
createAddressCache() アドレス アドレス → パス・公開鍵の覚えを作る
findAddressPath(address, xpub, acct, net, cache?, opts?) アドレス アドレスからパスを逆に引く
scanAccount(xpub, acct, net, opts?) 端点 口座を走査して UTXO と次のお釣りを得る
addressStats(address, net, deps?) 端点 アドレス 1 本が使われたか(取引の数・受け取った額・使った額)
fetchUtxos(addresses, net, deps?) 端点 渡したアドレスの UTXO を集める
fetchPrevTx(txid, net, deps?) 端点 前の取引の生 hex(Legacy の入力に必要)
getFeeRate(net, deps?) 端点 料率(sat/vB)
broadcastTx(rawTxHex, net, expectedTxid?, deps?) 端点 生の取引をブロードキャスト
selectUtxos(utxos, opts) 組み立て 使う UTXO を選ぶ
estimateVbytes(numInputs, script, outputVbytes) 組み立て 取引の大きさ(vB)の見積り
buildPsbt(args) 組み立て PSBT を組む
extractRawTx(signedPsbtHex) 組み立て 署名済み PSBT から生の取引と txid を取り出す

BIP-32 の補助(deriveChildPublicKey など)と定数は後述します。


pathOf

function pathOf(acct: BtcAccount, net: BtcNetworkId, change: 0 | 1, index: number): string
function accountPathOf(acct: BtcAccount, net: BtcNetworkId): string
引数 説明
acct 口座(script が purpose を、account が 3 段目を決める)
net 網(coin type を決める。Testnet3 も Testnet4 も 1)
change 0: 外部(受け取り)の列、1: お釣りの列
index 末端の番号(0 以上 2^31 未満の整数)
import { pathOf, accountPathOf } from '@openloop/sdk-core/bitcoin'

pathOf({ script: 'p2wpkh', account: 0 }, 'testnet4', 1, 3)   // "m/84'/1'/0'/1/3"
pathOf({ script: 'p2pkh', account: 1 }, 'mainnet', 0, 0)     // "m/44'/0'/1'/0/0"
accountPathOf({ script: 'p2sh-p2wpkh', account: 0 }, 'mainnet') // "m/49'/0'/0'"

不明な script / net、整数でない・範囲外の account / index、0 / 1 以外の change は BtcError(bad_argument)です。

publicKeyToAddress

function publicKeyToAddress(publicKey: string, script: BtcScript, net: BtcNetworkId): string

圧縮公開鍵(hex・33 バイト・先頭 02 / 03、0x 付きも可)から、型ごとのアドレスを作ります。

script 作るアドレス
'p2pkh' base58check(0x00(testnet は 0x6F)‖ hash160(公開鍵))
'p2sh-p2wpkh' base58check(0x05(testnet は 0xC4)‖ hash160(0x0014 ‖ hash160(公開鍵)))
'p2wpkh' bech32(bc / tb、witness v0、hash160(公開鍵))
import { publicKeyToAddress } from '@openloop/sdk-core/bitcoin'

const compressed = '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'
publicKeyToAddress(compressed, 'p2wpkh', 'mainnet')       // "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"
publicKeyToAddress(compressed, 'p2pkh', 'mainnet')        // "1BgGZ9tcN4rm9KBzDn7KprQz87SZ26SAMH"
publicKeyToAddress(compressed, 'p2sh-p2wpkh', 'testnet4') // "2NAUYAHhujozruyzpsFRP63mbrdaU5wnEpN"

圧縮公開鍵でない文字列(非圧縮の 65 バイトを含む)は BtcError(bad_argument)です。getAddress が返す publicKey は非圧縮なので、そのままは渡せません。

deriveAddresses

function deriveAddresses(
  xpub: BtcXpub,
  acct: BtcAccount,
  net: BtcNetworkId,
  change: 0 | 1,
  from: number,
  count: number
): Array<{ address: string; path: string; publicKey: string }>

口座の拡張公開鍵から、change の列の from 番目から count 本のアドレスを導出します。デバイスには触れません。

import { deriveAddresses } from '@openloop/sdk-core/bitcoin'

const receive = deriveAddresses(xpub, acct, net.id, 0, 0, 5) // 受け取り 0〜4
console.log(receive[0].address, receive[0].path)             // "tb1q…" "m/84'/1'/0'/0/0"

validateAddress

function validateAddress(address: string, net: BtcNetworkId): { kind: BtcAddressKind }

宛先のアドレスを検めます。bech32・bech32m・base58check の 3 方式をすべて読み、網を突き合わせます(試験網のアドレスに本物のコインを送金できないようにするため)。

kind 読める形
'p2wpkh' witness v0・20 バイト(bc1q… / tb1q…)
'p2wsh' witness v0・32 バイト
'p2tr' witness v1・32 バイト(bc1p… / tb1p…)
'p2pkh' base58check(1… / m… / n…)
'p2sh' base58check(3… / 2…)
投げる code 条件
invalid_address アドレスとして読めない(空・検査符号・文字・長さ・未対応の witness バージョン)
network_mismatch アドレスとしては読めるが、指定した網のものではない

invalid_address と network_mismatch を分けて返すので、後者のときだけ「網を切り替えますか」と案内できます。

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

try {
  const { kind } = validateAddress('tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx', 'mainnet')
  console.log(kind)
} catch (e) {
  if (e instanceof BtcError && e.code === 'network_mismatch') {
    console.log('別の網のアドレスです')
  }
}

addressToScriptPubKey

function addressToScriptPubKey(address: string, net: BtcNetworkId): Uint8Array

アドレスを scriptPubKey にします。先に validateAddress と同じ検査をするので、同じ code(invalid_address / network_mismatch)を投げます。

encodeSegwitAddress / decodeSegwitAddress

function encodeSegwitAddress(hrp: string, version: number, program: Uint8Array): string
function decodeSegwitAddress(address: string): { hrp: string; version: number; program: Uint8Array }

witness アドレスの符号化(v0 は bech32、v1 以降は bech32m)と、検査符号を照合する展開です。大文字だけのアドレスは読めますが、大小の混在は断ります。BtcError ではなく通常の Error を投げます。

createAddressCache

function createAddressCache(): BtcAddressCache

interface BtcAddressCache {
  get(address: string): { path: string; publicKey: string } | undefined
  set(address: string, entry: { path: string; publicKey: string }): void
  syncToDevice(deviceId: string | null): void
  readonly size: number
  clear(): void
}

アドレス → パス・公開鍵 の覚えです。モジュールに 1 つではなく、呼ぶ側が持ちます(網・型・口座ごとに分けて持てます)。scanAccount と findAddressPath に渡すと、導出したアドレスを覚えます。

syncToDevice(deviceId) は持ち主のデバイスを宣言します。持ち主が変わると全部捨てます(別のデバイスのアドレスを自分のものとして扱うと、署名できないパスを渡すことになるため)。同じ非 null の ID なら何もしません。null(持ち主が分からない)は、そのたびに全部捨てます。

cache.syncToDevice('device-A')
cache.set('tb1q…', { path: "m/84'/1'/0'/0/0", publicKey: '02…' })
cache.syncToDevice('device-B') // 持ち主が変わった → 全部捨てる
console.log(cache.size)        // 0

findAddressPath

function findAddressPath(
  address: string,
  xpub: BtcXpub,
  acct: BtcAccount,
  net: BtcNetworkId,
  cache?: BtcAddressCache,
  opts?: { maxIndex?: number; step?: number }
): string | null

アドレスからパスを逆に引きます。まず cache を見て、無ければ受け取りの列 → お釣りの列の順に、step(既定 20)本ずつ導出して 0〜maxIndex - 1(既定 200)まで探します。途中で導出したアドレスは cache に覚えます。

見つからなければ null を返します(投げません。自分のものでないアドレスは誤りではないため)。maxIndex / step が 1 未満・整数でない、address が空のときは BtcError(bad_argument)です。

import { findAddressPath } from '@openloop/sdk-core/bitcoin'

const path = findAddressPath(nextChange.address, xpub, acct, net.id, cache)
// "m/84'/1'/0'/1/0" など。見つからなければ null

scanAccount

function scanAccount(
  xpub: BtcXpub,
  acct: BtcAccount,
  net: BtcNetwork,
  opts?: {
    cache?: BtcAddressCache
    gapLimit?: number      // 既定 20
    concurrency?: number   // 既定 4(API_PARALLEL_LIMIT)
    knownUsed?: Array<{ chain: 0 | 1; index: number }>  // 呼ぶ側が「使われた」と知っている番号
  } & FetchDeps
): Promise<BtcScanResult>

interface BtcScanResult {
  used: Array<{ address: string; path: string }>  // 使われたことのあるアドレス(両方の列)
  utxos: BtcUtxo[]
  nextChange: { index: number; address: string; path: string; publicKey: string }
}

口座を走査します。受け取りの列とお釣りの列の両方を、使われていないアドレスが gapLimit 本続くまで調べ、残高のあるアドレスだけ UTXO を訊きます。

import { scanAccount } from '@openloop/sdk-core/bitcoin'

const { used, utxos, nextChange } = await scanAccount(xpub, acct, net, { cache, gapLimit: 20 })
const total = utxos.reduce((sum, u) => sum + u.value, 0)
console.log(`${used.length} 件のアドレスが使用済み、残高 ${total} sats`)
console.log('お釣りの次の宛先', nextChange.address, nextChange.path)

addressStats

function addressStats(
  address: string,
  net: BtcNetwork,
  deps?: FetchDeps
): Promise<BtcAddressStats>

interface BtcAddressStats {
  txCount: number   // 取引の数(確定+未確定)。0 より大きければ「使われた」
  funded: number    // 受け取った額の合計(sats・確定+未確定)
  spent: number     // 使った額の合計(sats・確定+未確定)。残高は funded - spent
}

アドレス 1 本が使われたかを端点(Esplora の address)に訊きます。WalletConnect 経由のように財布が返した住所だけで拡張公開鍵が無く、scanAccount が使えない呼び手が、お釣りに「取引の数が 0 の住所」を選ぶための口です。

fetchUtxos

function fetchUtxos(
  addresses: Array<{ address: string; path: string; publicKey: string }>,
  net: BtcNetwork,
  deps?: FetchDeps
): Promise<BtcUtxo[]>

渡したアドレスの UTXO を全部集めます(同時に最大 API_PARALLEL_LIMIT 本)。1 つでも全端点で失敗したら fetch_failed です。端点の答えは形を検め(txid・vout・額)、形が違えばその端点の失敗として次の端点に回します。addresses が配列でなければ bad_argument です。

import { fetchUtxos, deriveAddresses } from '@openloop/sdk-core/bitcoin'

const firstFive = deriveAddresses(xpub, acct, net.id, 0, 0, 5)
const found = await fetchUtxos(firstFive, net)

fetchPrevTx

function fetchPrevTx(txid: string, net: BtcNetwork, deps?: FetchDeps): Promise<string>

前の取引の生 hex(小文字)を取ります。Legacy(P2PKH)の入力は、前の取引の全体から額を読むので、buildPsbt に必要です。

投げる code 条件
bad_argument txid が 64 桁の hex でない、net.esplora が空
fetch_failed 全端点で取れなかった
bad_response 取れた取引が txid にならない(sha256d の逆順と照合します。嘘の額をデバイスに表示させないため)
prev_tx_too_large デバイスの上限(MAX_PREV_TX_BYTES = 4,096 バイト)を超える
import { fetchPrevTx } from '@openloop/sdk-core/bitcoin'

const prevTxHex = await fetchPrevTx(utxos[0].txid, net)

getFeeRate

function getFeeRate(net: BtcNetwork, deps?: FetchDeps): Promise<number>

料率(sat/vB)を取ります。feeEndpoints の /v1/fees/recommended の halfHourFee です(ここは mempool.space 独自で、Esplora の端点とは別です)。

外から来た値をそのまま使わず、MIN_FEE_RATE_SAT_VB(1)〜MAX_FEE_RATE_SAT_VB(5000)の外は丸めずに BtcError(fee_rate_out_of_range)にします。取得できなければ fetch_failed、net.feeEndpoints が空なら bad_argument です。

import { getFeeRate } from '@openloop/sdk-core/bitcoin'

const feeRate = await getFeeRate(net) // 例: 5

selectUtxos

function selectUtxos(
  utxos: BtcUtxo[],
  opts: {
    to: BtcRecipient[]            // { address: string; value: number }[]。1 件以上
    feeRate: number               // sat/vB
    script: BtcScript             // 入力の型(1 つの口座の中は同じ型)
    net: BtcNetworkId
    excluded?: Iterable<string>   // 使用済みの印 "txid:vout"
    changeAddress?: string        // お釣りの宛先。出力の大きさの見積りに使う
  }
): BtcSelection

interface BtcSelection {
  inputs: BtcUtxo[]
  fee: number      // 実際に払う手数料(sats)= 入力合計 − 出力合計
  change: number   // お釣り(sats)。0 ならお釣りの出力を作らない
  vbytes: number   // 見積もった大きさ(vB)
}

使う UTXO を選びます。

投げる code 条件
insufficient 残高が不足している
insufficient_unconfirmed 確認待ちを数えれば不足しない(待てば送金できる)
fee_too_high 手数料が MAX_ABSOLUTE_FEE_SATS(10,000,000 sats = 0.1 BTC)を超える
fee_exceeds_amount 送金額が FEE_FRACTION_MIN_AMOUNT_SATS(1,000,000 sats)以上で、手数料が送金額を超える
fee_rate_out_of_range feeRate が 1〜5000 の外
invalid_address / network_mismatch to / changeAddress のアドレスが不正、または別の網
bad_argument utxos が配列でない、to が空、value が正の整数でない、不明な script
import { selectUtxos, getFeeRate } from '@openloop/sdk-core/bitcoin'

const sel = selectUtxos(utxos, {
  to: [{ address: 'tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx', value: 20_000 }],
  feeRate: await getFeeRate(net),
  script: acct.script,
  net: net.id,
  excluded: new Set<string>(),
  changeAddress: nextChange.address,
})
console.log(sel.inputs.length, sel.fee, sel.change, sel.vbytes)

estimateVbytes

function estimateVbytes(numInputs: number, script: BtcScript, outputVbytes: number[]): number

取引の大きさ(vB)を見積もります。ceil(TX_OVERHEAD_VBYTES + 入力数 × INPUT_VBYTES[script] + 出力の vB の合計) です。出力の vB は OUTPUT_VBYTES(宛先の型ごと)を渡します。不明な script は bad_argument です。

import { estimateVbytes, OUTPUT_VBYTES } from '@openloop/sdk-core/bitcoin'

// P2WPKH 入力 2 本、P2WPKH 宛 1 件 + お釣り 1 件
estimateVbytes(2, 'p2wpkh', [OUTPUT_VBYTES.p2wpkh, OUTPUT_VBYTES.p2wpkh]) // 209

buildPsbt

function buildPsbt(args: {
  script: BtcScript
  net: BtcNetworkId
  inputs: BtcPsbtInput[]
  to: Array<{ address: string; value: number }>
  change?: BtcChangeOutput   // お釣りの出力を作らないときは渡さない
}): BtcPsbt

interface BtcPsbtInput extends BtcUtxo {
  prevTxHex?: string         // Legacy('p2pkh')のときだけ必須
}
interface BtcChangeOutput {  // scanAccount の nextChange に value を足した形
  address: string
  path: string
  publicKey: string
  value: number
}
interface BtcPsbt {
  psbtHex: string
  paths: Array<{ index: number; path: string }>   // signPsbtWithPaths にそのまま渡せる
}

PSBT(v0)を組みます。取引の version は 2、locktime は 0、全入力の sequence は RBF_SEQUENCE(0xfffffffd)で、RBF(手数料の引き上げ)が可能です。

入力の型 付けるもの
'p2wpkh' WITNESS_UTXO + BIP32_DERIVATION
'p2sh-p2wpkh' WITNESS_UTXO + REDEEM_SCRIPT + BIP32_DERIVATION
'p2pkh' NON_WITNESS_UTXO(prevTxHex)+ BIP32_DERIVATION
投げる code 条件
bad_argument 入力・宛先が空、value が正の整数でない、txid・公開鍵・hex が不正、Legacy で prevTxHex が無い、または prevTxHex が txid に一致しない
prev_tx_too_large prevTxHex が MAX_PREV_TX_BYTES(4,096)を超える
invalid_address / network_mismatch 宛先・お釣りのアドレスが不正、または別の網

組んだ大きさで事前に断ることはしません。大きすぎる PSBT はデバイスが断ります。

import { buildPsbt } from '@openloop/sdk-core/bitcoin'

const to = [{ address: 'tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx', value: 20_000 }]
const { psbtHex, paths } = buildPsbt({
  script: acct.script,
  net: net.id,
  inputs: sel.inputs,
  to,
  change: sel.change > 0 ? { ...nextChange, value: sel.change } : undefined,
})

Legacy では、入力ごとに前の取引を取って渡します。

import { fetchPrevTx, buildPsbt, type BtcAccount } from '@openloop/sdk-core/bitcoin'

const legacyAcct: BtcAccount = { script: 'p2pkh', account: 0 }
const withPrev = await Promise.all(
  sel.inputs.map(async u => ({ ...u, prevTxHex: await fetchPrevTx(u.txid, net) }))
)
const legacy = buildPsbt({
  script: legacyAcct.script,
  net: net.id,
  inputs: withPrev,
  to: [{ address: 'tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx', value: 20_000 }],
})

extractRawTx

function extractRawTx(signedPsbtHex: string): { rawTxHex: string; txid: string }

署名済み PSBT(signPsbtWithPaths / signPsbtHex の返り値)から、ブロードキャストできる生の取引と txid を取り出します。3 型すべてに対応し、入力ごとの FINAL_SCRIPTSIG(0x07)と FINAL_SCRIPTWITNESS(0x08)を読みます。1 本でも witness があれば、marker と flag を付けた形(BIP-144)にします。署名の検証はしません。

PSBT として読めない(マジック不一致・途中で切れている・最終化されていない入力がある)ときは BtcError(bad_psbt)、hex でない文字列は bad_argument です。

import { extractRawTx } from '@openloop/sdk-core/bitcoin'

const { rawTxHex, txid } = extractRawTx(signedHex)

broadcastTx

function broadcastTx(
  rawTxHex: string,
  net: BtcNetwork,
  expectedTxid?: string,
  deps?: FetchDeps
): Promise<string>   // txid

生の取引を POST /tx でブロードキャストし、txid を返します。端点は最良から順に試し、受理した端点の答えを他の端点へ再送しません(二重のブロードキャストになりません)。

投げる code 条件
bad_argument rawTxHex が hex でない
broadcast_rejected 全端点がブロードキャストを受け付けなかった(到達不能・HTTP の誤り・txid でない答えも含みます。fetch_failed にはなりません)
bad_response 端点が返した txid が expectedTxid と一致しない
import { broadcastTx } from '@openloop/sdk-core/bitcoin'

const sentTxid = await broadcastTx(rawTxHex, net, txid)

BtcError

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 が分岐の鍵です。メッセージは英語で、SDK は翻訳を持ちません。

code 意味 主に投げる所
bad_argument 引数がおかしい(パス・index・網・長さ・形)。呼ぶ側の作りの誤り 全般
invalid_address アドレスとして読めない validateAddress / selectUtxos / buildPsbt
network_mismatch 読めるが、指定した網のものではない 同上
fetch_failed 端点に訊けなかった(空として扱わない) scanAccount / fetchUtxos / fetchPrevTx / getFeeRate
bad_response 端点の答えが期待と違う fetchPrevTx / broadcastTx
prev_tx_too_large 前の取引がデバイスの上限(4,096 バイト)を超える fetchPrevTx / buildPsbt
fee_rate_out_of_range 料率が 1〜5000 sat/vB の外 getFeeRate / selectUtxos
insufficient 残高が不足している selectUtxos
insufficient_unconfirmed 確認待ちを数えれば不足しない selectUtxos
fee_too_high 手数料が絶対額の上限(0.1 BTC)を超える selectUtxos
fee_exceeds_amount 手数料が送金額を超える selectUtxos
psbt_too_large 署名済み PSBT が読み戻しの上限(64,000 バイト)を超えた BitcoinApp の PSBT 署名(@openloop/sdk-core 側の BtcError)
bad_psbt PSBT として読めない extractRawTx
broadcast_rejected ブロードキャストが受け付けられなかった broadcastTx

FetchDeps と再試行

端点に訊く関数(scanAccount の opts、fetchUtxos / fetchPrevTx / getFeeRate / broadcastTx の deps)は、次の形の追加引数を受け付けます。

interface FetchDeps {
  fetchImpl?: typeof fetch   // 既定: 実行環境の globalThis.fetch
  retryDelayMs?: number      // 再試行の間隔(ミリ秒)。既定 API_RETRY_DELAY_MS(1000)
  retryCount?: number        // 1 つの URL への試行回数。既定 API_RETRY_COUNT(3)
}

端点は esplora(または feeEndpoints)の先頭から順に、1 つの URL を retryCount 回まで試します。HTTP 200 で、本文が期待する形だった最初の答えを返します。全端点が失敗したら fetch_failed です。fetch が無い実行環境では fetchImpl が必要です。

import { getFeeRate } from '@openloop/sdk-core/bitcoin'

const fetchImpl: typeof fetch = (url, init) => fetch(url, init) // 例: 計測や代理の差し込み
const rate = await getFeeRate(net, { fetchImpl, retryCount: 1, retryDelayMs: 0 })

定数

すべて @openloop/sdk-core/bitcoin から import できます。

定数 値 説明
MIN_FEE_RATE_SAT_VB 1 料率の下限(sat/vB)
MAX_FEE_RATE_SAT_VB 5000 料率の上限。これを超える値は提供元の異常とみなす
MAX_ABSOLUTE_FEE_SATS 10_000_000 1 回の送金で払える手数料の絶対上限(0.1 BTC)
MAX_FEE_FRACTION 1.0 手数料が送金額に対して許される割合の上限
FEE_FRACTION_MIN_AMOUNT_SATS 1_000_000 上の割合の門を当てる送金額の下限
DUST_SATS 546 お釣りがこれ未満なら出力を作らず手数料に回す
MAX_PREV_TX_BYTES 4096 前の取引の大きさの上限(デバイスの上限)
PSBT_RESPONSE_CHUNK_SIZE 250 署名済み PSBT の 1 かたまりの大きさ(バイト)
PSBT_MAX_RESPONSE_CHUNKS 256 読み戻せるかたまりの数(P1 は 1 バイト)
MAX_SIGNED_PSBT_BYTES 64000 署名済み PSBT を読み戻せる上限(256 × 250)
RBF_SEQUENCE 0xfffffffd 全入力の sequence(RBF)
INPUT_VBYTES { p2pkh: 148, 'p2sh-p2wpkh': 91, p2wpkh: 68 } 入力 1 本の vB(上限側で数える)
OUTPUT_VBYTES { p2wpkh: 31, p2tr: 43, p2wsh: 43, p2pkh: 34, p2sh: 32 } 出力 1 本の vB(宛先の型ごと)
TX_OVERHEAD_VBYTES 10.5 取引の頭の vB
PURPOSE_OF_SCRIPT { p2pkh: 44, 'p2sh-p2wpkh': 49, p2wpkh: 84 } 型 → BIP-43 の purpose
COIN_TYPE_OF_NETWORK { mainnet: 0, testnet3: 1, testnet4: 1 } 網 → BIP-44 の coin type
API_RETRY_COUNT 3 1 つの URL への試行回数の既定
API_RETRY_DELAY_MS 1000 再試行の間隔の既定(ミリ秒)
API_PARALLEL_LIMIT 4 同時に走らせる取得の数の既定

BIP-32 の補助

公開鍵だけで行う子の導出と、拡張公開鍵の読み書きです。@openloop/sdk-core と @openloop/sdk-core/bitcoin のどちらからも import できます(同じ実装を再 export したものです)。

名前 説明
deriveChildPublicKey(parentPubkey, parentChainCode, index) 非強化の子の公開鍵とチェーンコードを導出({ publicKey, chainCode }、hex)。index は 0 以上 2^31 未満の整数
decodeExtendedPubkey(serialized) xpub… / tpub… を ExtendedPublicKey(version / depth / parentFingerprint / childNumber / chainCode / publicKey)に読む。xprv や曲線上にない点は断る
base58CheckDecode(str) / base58CheckEncode(payload) base58check(4 バイトの検査符号を必ず照合する)。Decode は検査符号を除いた Uint8Array を返す
BIP32_VERSION_XPUB / BIP32_VERSION_TPUB 拡張公開鍵の版(0x0488b21e / 0x043587cf)

秘密鍵は扱いません(強化導出はできません)。不正な入力は通常の Error を投げます。


送金の全体像

ここまでの関数を組み合わせると、次のようになります(Native SegWit・testnet4 の例)。

import { BitcoinApp, type ITransport } from '@openloop/sdk-core'
import {
  BtcError,
  accountPathOf,
  broadcastTx,
  buildPsbt,
  createAddressCache,
  extractRawTx,
  getFeeRate,
  scanAccount,
  selectUtxos,
  validateAddress,
  type BtcAccount,
  type BtcNetwork,
} from '@openloop/sdk-core/bitcoin'

const net: BtcNetwork = {
  id: 'testnet4',
  esplora: ['https://mempool.space/testnet4/api'],
  feeEndpoints: ['https://mempool.space/testnet4/api'],
  timeoutMs: 15_000,
}
const acct: BtcAccount = { script: 'p2wpkh', account: 0 }

async function send(transport: ITransport, toAddress: string, sats: number): Promise<string> {
  const btc = new BitcoinApp(transport)

  validateAddress(toAddress, net.id)                           // 宛先と網の確認

  const xpub = await btc.getExtendedPubkey(accountPathOf(acct, net.id))
  const cache = createAddressCache()
  const { utxos, nextChange } = await scanAccount(xpub, acct, net, { cache })

  const sel = selectUtxos(utxos, {
    to: [{ address: toAddress, value: sats }],
    feeRate: await getFeeRate(net),
    script: acct.script,
    net: net.id,
    changeAddress: nextChange.address,
  })

  const { psbtHex, paths } = buildPsbt({
    script: acct.script,
    net: net.id,
    inputs: sel.inputs,
    to: [{ address: toAddress, value: sats }],
    change: sel.change > 0 ? { ...nextChange, value: sel.change } : undefined,
  })

  const signedHex = await btc.signPsbtWithPaths(psbtHex, paths) // デバイスで確認・承認
  const { rawTxHex, txid } = extractRawTx(signedHex)
  return broadcastTx(rawTxHex, net, txid)
}

async function main(transport: ITransport) {
  try {
    const txid = await send(transport, 'tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx', 20_000)
    console.log('ブロードキャストしました', txid)
  } catch (e) {
    if (e instanceof BtcError) {
      console.log(e.code, e.message) // 例: 'insufficient'
    } else {
      throw e // ApduError(拒否など)・TransportError
    }
  }
}

署名の検証

デバイスが返した署名を、参照実装(@noble)で検証します。@openloop/sdk-core/verify サブパスで、Transport には依存しません。全体は API リファレンス にあります。

関数 検証対象
verifyBtcMessage(message, sig, identity) BIP-137 メッセージ署名
verifyBtcPsbt(signedPsbtHex) 署名済み PSBT の署名

どちらも VerifyResult({ recovered: string; expected?: string; ok: boolean })を返します。

verifyBtcMessage

function verifyBtcMessage(message: string, sig: BtcMessageSignature, identity: string): VerifyResult

signMessage の返り値 sig から公開鍵を復元し、identity と照合します。identity は公開鍵(hex・圧縮 33 バイトまたは非圧縮 65 バイト)か、アドレスです。アドレスで照合できるのは、公開鍵の hash160 を持つ形(bech32 の P2WPKH と base58 の P2PKH)で、P2SH-P2WPKH のアドレス(償還スクリプトの hash160)とは一致しません。その場合は公開鍵を渡してください。壊れた署名は例外ではなく ok: false を返します。

import { verifyBtcMessage } from '@openloop/sdk-core/verify'

const message = 'Hello, Bitcoin!'
const sig = await btc.signMessage(message, BTC_MAINNET_PATH)
const { address } = await btc.getAddress()
const result = verifyBtcMessage(message, sig, address)
console.log(result.ok) // true

verifyBtcPsbt

function verifyBtcPsbt(signedPsbtHex: string): VerifyResult

署名済み PSBT(signPsbtHex などの返り値)の署名を、署名ハッシュを再計算して検証します。検証できる入力の型は 3 つです。

型 署名ハッシュ
Native SegWit(P2WPKH) BIP-143
Nested SegWit(P2SH-P2WPKH) BIP-143(償還スクリプトから公開鍵のハッシュを確認)
Legacy(P2PKH) 従来の署名ハッシュ(NON_WITNESS_UTXO の前の取引から、使う出力の scriptPubKey を読んで差し替える)

ok が署名の暗号学的な正しさです。公開鍵は PSBT の中から読み、expected に入ります。この 3 型以外の入力や、読めない形の PSBT は例外になります。

import { verifyBtcPsbt } from '@openloop/sdk-core/verify'

const signedHex = await btc.signPsbtHex('70736274ff...')
const result = verifyBtcPsbt(signedHex)
console.log(result.ok)

PSBT プロトコル詳細

PSBT 署名はデバイスとの 3 フェーズ通信で行われます(CLA 0xF0)。

Diagram 0

Phase 1: PSBT 送信

チャンク P1 データ形式
最初 0x00 [4B total_size][1B num_paths][paths...][psbt_data...]
継続 0x80 [psbt_data...]
最終 0xFF [remaining_data] → デバイスで署名開始

Phase 2: デバイスで署名

ユーザーがデバイス画面でトランザクション内容を確認し、承認ボタンを押します。拒否した場合は ApduError(0x6985)になります。デバイスは署名のあと、入力を最終化して PSBT を保持します。

Phase 3: 署名済み PSBT 取得

レスポンス ステータスワード 意味
[signed_psbt_chunk] 0x61XX 続きデータあり(次のチャンクを要求)
[signed_psbt_chunk] 0x9000 最終チャンク(取得完了)

チャンクインデックス(P1)を 0 から順にインクリメントし、0x9000 が返るまで繰り返します。Transport は 0x61XX を例外にせず応答の末尾 2 バイトとして返し、SDK は末尾が 0x61XX なら続きありと判断して、その 2 バイトを外して連結します。

1 チャンクは 250 バイト(PSBT_RESPONSE_CHUNK_SIZE)、P1 は 1 バイトなので、読めるのは 256 チャンク(MAX_SIGNED_PSBT_BYTES = 64,000 バイト)までです。超えると BtcError(psbt_too_large)で止まります。


bitcoinjs-lib 連携

bitcoinjs-lib で組んだ PSBT を署名します。デバイスが返す PSBT は最終化済みなので、finalizeAllInputs() は呼ばず(呼ぶと Can not finalize input になります)、そのまま extractTransaction() します。

import * as bitcoin from 'bitcoinjs-lib'
import { BitcoinApp, BTC_MAINNET_PATH, type ITransport } from '@openloop/sdk-core'

async function signWithBitcoinjs(transport: ITransport): Promise<string> {
  const btc = new BitcoinApp(transport)

  // アドレス取得
  const { address } = await btc.getAddress()

  // PSBT 作成
  const psbt = new bitcoin.Psbt({ network: bitcoin.networks.bitcoin })
  psbt.addInput({
    hash: '7d067b4a697a09d2c3cff7d4d9506c9955e93bff41bf82d439da7d030382bc3e',
    index: 0,
    witnessUtxo: {
      script: bitcoin.address.toOutputScript(address, bitcoin.networks.bitcoin),
      value: 50000,
    },
  })
  psbt.addOutput({
    address: 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4',
    value: 40000,
  })

  // PSBT をデバイスで署名
  const psbtHex = psbt.toHex()
  const signedHex = await btc.signPsbtWithPaths(psbtHex, [
    { index: 0, path: BTC_MAINNET_PATH },
  ])

  // 署名済み PSBT(最終化済み)を読み込み、ブロードキャスト用 raw transaction を取り出す
  const signedPsbt = bitcoin.Psbt.fromHex(signedHex)
  return signedPsbt.extractTransaction().toHex()
}

サブパスの buildPsbt / extractRawTx を使えば、bitcoinjs-lib なしで同じことができます(送金の全体像)。

次のステップ