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 にあります(署名の検証)。
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 は使えません)。
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..."任意の 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")アカウントレベルの公開鍵とチェーンコード(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
を投げます。
任意のパスの拡張公開鍵を取得します(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 を投げます。
depth が訊いた段数と一致しない1 なのに tpub
でない、またはそれ以外なのに xpub でない(2
段未満のパスでは確認しません)10 段を超えるパスと、数値として読めない段も、デバイスに送信する前に
Error で断ります。
xpub/tpub
です(ypub・zpub にはなりません。Ledger
のプロトコル v2 と同じです)。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) |
ウォレットの 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)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 signaturePSBT(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'
}
}hex 文字列での PSBT 署名。入出力ともに hex 文字列です(出力に
0x は付きません)。
async signPsbtHex(psbt: string): Promise<string>const signedHex = await btc.signPsbtHex('70736274ff...')各入力の 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" }, // お釣り
])デバイスに触れずに、送金の前後を組み立てる関数群です。SDK は UI も既定のサーバーも持たないので、次の 3 つを守っています。
BtcError の
code(英語のメッセージは付きますが、分岐には
code
を使います)で返します。画面の言葉は呼ぶ側が決めます。fetch_failed で止めます(「残高
0」と読んで送金を組むことはありません)。以降の断片は、次の変数が定義済みとして書いています。
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'1)。BtcXpub は getExtendedPubkey の返り値です。script から、coin type は
id から決まるので、BtcAccount
は持ちません(PURPOSE_OF_SCRIPT /
COIN_TYPE_OF_NETWORK)。| 関数 | 分類 | 説明 |
|---|---|---|
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 など)と定数は後述します。
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)です。
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
は非圧縮なので、そのままは渡せません。
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
本のアドレスを導出します。デバイスには触れません。
xpub
は口座の段(m/purpose'/coin'/account'、depth
が 3)の BtcXpub です。depth
が違うと別の口座のアドレスを黙って作るので、BtcError(bad_argument)で断ります。from / count は 0
以上の整数です(count が 0
なら空配列)。違えば bad_argument です。path は "m/84'/1'/0'/0/5"
の形、publicKey は圧縮公開鍵(hex)です。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"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('別の網のアドレスです')
}
}function addressToScriptPubKey(address: string, net: BtcNetworkId): Uint8Arrayアドレスを scriptPubKey にします。先に validateAddress
と同じ検査をするので、同じ
code(invalid_address /
network_mismatch)を投げます。
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 を投げます。
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) // 0function 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" など。見つからなければ nullfunction 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 を訊きます。
nextChange は、お釣りの列で取引の無い最初の
index です。buildPsbt の change
にそのまま渡せます(アドレスを再利用しないため)。fetch_failed で止まります。xpub.depth が 3 でないと
bad_argument。gapLimit /
concurrency が 1 未満・整数でない場合も
bad_argument です。utxos
には、確認の無いもの(confirmed: false)も入ります。opts.knownUsed(Array<{ chain: 0 | 1; index: number }>):
呼ぶ側が「使われた」と知っている番号です。端点が未使用と答えても使用済みに数え(used
に入り、gap を数え直し、nextChange
はそこを飛ばします)。試験網(Testnet3/4)は同じ鍵を共有するので、もう片方の網で使った番号を渡せます。端点にも訊くので残高は端点の答えのままです。渡さなければ今までと同じで、不正な指定は
bad_argument です。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)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
の住所」を選ぶための口です。
fetch_failed)。net
に合わない住所は端点に訊く前に断ります(invalid_address/network_mismatch)。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)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)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) // 例: 5function 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 を選びます。
confirmed: true)だけを候補にします。
自分のお釣りも、確認がつくまで使いません。excluded
に入力した "txid:vout" の UTXO も外します。ceil(vbytes × feeRate)。大きさは入力の型・宛先の型・お釣りの出力で見積もります(estimateVbytes)。changeAddress
を省略すると、お釣りは入力と同じ型として数えます。DUST_SATS(546)未満なら出力を作らず、手数料に加えます(change
は 0、fee は入力合計 − 送金額)。投げる 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)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]) // 209function 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 |
BIP32_DERIVATION のフィンガープリントは
00000000 です。パスは返り値の paths を signPsbtWithPaths
に渡して指定します。address ではなく
publicKey から作ります。change の出力にも BIP32_DERIVATION
を付けます。デバイスが自分の鍵と照合し、お釣りを「送り先」として表示しません。prevTxHex は、渡す前に txid
と照合します。投げる 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 }],
})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)function broadcastTx(
rawTxHex: string,
net: BtcNetwork,
expectedTxid?: string,
deps?: FetchDeps
): Promise<string> // txid生の取引を POST /tx でブロードキャストし、txid
を返します。端点は最良から順に試し、受理した端点の答えを他の端点へ再送しません(二重のブロードキャストになりません)。
200 <html> を txid として返さないため)。expectedTxid(extractRawTx
が返した txid)を必ず渡してください。 渡すと、次の
2 点が効きます。
expectedTxid
を返します(同じ取引の再送で返る答えです)。expectedTxid と違えば
bad_response にします。投げる 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)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 |
端点に訊く関数(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 |
同時に走らせる取得の数の既定 |
公開鍵だけで行う子の導出と、拡張公開鍵の読み書きです。@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 })を返します。
function verifyBtcMessage(message: string, sig: BtcMessageSignature, identity: string): VerifyResultsignMessage の返り値 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) // truefunction 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 署名はデバイスとの 3
フェーズ通信で行われます(CLA 0xF0)。
| チャンク | P1 | データ形式 |
|---|---|---|
| 最初 | 0x00 |
[4B total_size][1B num_paths][paths...][psbt_data...] |
| 継続 | 0x80 |
[psbt_data...] |
| 最終 | 0xFF |
[remaining_data] → デバイスで署名開始 |
num_paths=0: PSBT 内の BIP32_DERIVATION
から自動検出num_paths>0:
各入力のパスを明示指定([1B input_index][1B path_len][4B * path_len elements]、各要素はビッグエンディアン・強化は
0x80000000)ユーザーがデバイス画面でトランザクション内容を確認し、承認ボタンを押します。拒否した場合は
ApduError(0x6985)になります。デバイスは署名のあと、入力を最終化して
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 で組んだ 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 なしで同じことができます(送金の全体像)。