2026-10-07

PSBT を理解したい

#Bitcoin #PSBT
SeedSignerSeedSigner は何を信頼して動いているのか

SeedSignerについて紹介したが、使っていながらも正直PSBTについてあんまり理解していない。
我々は何をエアギャップで送信しているのだろうとふと思った。
最近自前でDIYSignerも作ってみようと思っているので、なおさら理解しないといけない。
なので、PSBTを理解するために手を動かしたりまとめてみた。

PSBT とは

PSBT (Partially Signed Bitcoin Transaction) は BIP174 で定められた、署名が揃っていない取引をやりとりするためのバイナリ形式。
2017 年に提案され、Bitcoin Core には 0.17 で RPC として入った。

BIP174: Partially Signed Bitcoin Transaction Format署名者が署名を作るのに必要な情報を、取引と一緒に運ぶ形式。Status: Deployedgithub.com

BIP174 の Abstract では、PSBT を「署名に必要な情報を含み、すべての署名が揃うまで部分署名を保持する形式」と説明している。
必要な情報が取引と一緒に入っているため、署名者はオフラインのまま署名できる。

BIP174 の Motivation では、主に 2 つの問題を挙げている。

  • 未署名の取引を複数の署名者に渡す方法がウォレットごとに異なり、別のソフト間で受け渡せなかったこと
  • 署名に使う UTXO の情報が必要なのに、エアギャップ端末やハードウェアウォレットは UTXO セットを直接参照できないこと

エアギャップなSignerで送金処理を整理すると以下のような流れになり、
PSBT形式のデータをアニメーションQRにすればやりとりができるということだろう。
確かに、ウォレットや署名端末の互換性は、このPSBT形式のQRでやりとりできるかというところだけ考えればよいことになる。

ウォレット ネットにつながる 鍵は持たせない 画面 カメラ ブロードキャスト 署名端末 ネットにつながらない 鍵を持つ カメラ シードフレーズ 1 2 3 4 5 6 7 8 9 10 11 12 → 鍵を作って署名 画面 1 未署名の PSBT QR 4 枚 2 署名を足した PSBT QR 7 枚 AIR GAP ケーブルも無線もない 3 署名がそろったら、ブロードキャストで取引を Bitcoin のネットワークへ送る
ウォレットが画面に出した未署名の PSBT を署名端末のカメラで読み、署名を足した PSBT を今度は署名端末の画面からウォレットのカメラで読み返す。QR はどちらも実データで、署名だけダミー

役割ごとの PSBT の利用方法

PSBTは上の図のように、送る内容が異なる。
BIP174 は、PSBT を扱う処理を役割 (Roles) に分けて定義している。
1 つのソフトが複数の役割を兼ねてもよく、実際そのような実装が多い。
なんとなく、整理してみた表が以下。

役割 図での担当 やること
Creator ウォレット 未署名の取引を作って PSBT に入れる。input / output の map は空のまま
Updater ウォレット 使う UTXO、script、BIP32 の導出パスなど、知っている情報を足す
Signer 署名端末 中身を確かめ、自分の鍵で署名できる input に部分署名を足す
Combiner ウォレット (複数署名時) 同じ取引の PSBT を複数受け取り、1 つにまとめる
Input Finalizer ウォレット 署名が揃った input の scriptSig / scriptWitness を組み立てる
Transaction Extractor ウォレット 全 input が揃った PSBT から、ネットワークに流せる取引を取り出す

Bitcoin Core の doc/psbt.md では、RPC ごとの役割が整理されている。たとえば walletcreatefundedpsbt は Creator と Updater、walletprocesspsbt は Updater・Signer・Finalizer、finalizepsbt は Finalizer と Extractor を担う。
こういうユースケース的なものもRPCで整理されてるんだぁと関心した。

Bitcoin Core doc/psbt.mdPSBT Howto for Bitcoin Core。役割ごとの流れと対応する RPCgithub.com

図の流れでは、署名端末が署名を加え、ウォレット側で取引を完成させてブロードキャストする。
Signer は情報を追加できるが、受け取った PSBT から何かを削除してはいけない。
ウォレットと署名端末の間では、PSBT を UR 形式のテキストに変換し、QR で受け渡す。

送金情報が UR になるまで

PSBT はバイナリ形式だ。でもバイナリというよりQRを扱っているというほうが我々の直感的にはあっていると思う。
バイナリデータを QR で運ぶために定めた形式がUR (Uniform Resources) というそう。
Blockchain Commons が定めた。
BIP ではないが、多くのエアギャップ端末やウォレットが採用している。

BCR-2020-005: Uniform Resourcesバイナリを型付きの URI 風テキストにし、分割して複数の QR で運ぶ形式github.com

送金内容が PSBT になり、UR を経て QR で受け渡されるまでを、送信側から順に見ていく。

0 送金画面で入力する Sparrow Wallet や BlueWallet など SEND 送金先 bc1qzyg3zyg3…zyg3h8ffkz 金額 1.00000000 BTC 手数料 sat/vB PSBT を作る ウォレットが自分で足す 使うコインを自分の UTXO から選ぶ 金額と script は手元の履歴にある 署名に使う鍵の場所を添える xpub から master fingerprint と導出パス 1 PSBT の材料 CREATOR / UPDATER unsigned transaction 入力・出力・金額・locktime witness UTXO 使うコインの金額と script BIP32 derivation 公開鍵・fingerprint・導出パス 2 PSBT にまとめる 187 byte magic 5 global map unsigned tx · 86 byte input map UTXO + derivation · 95 byte output 1 3 CBOR の byte string に包む 189 byte · CRC32 61e6e143 58 bb PSBT 187 byte 4 48 byte ずつに分ける 4 fragments fragment 1 0–47 fragment 2 48–95 fragment 3 96–143 fragment 4 144–188 + 0 ×3 fragment 1 0–47 fragment 2 48–95 fragment 3 96–143 fragment 4 144–188 + 0 ×3 5 fragment 1 を part (CBOR の配列) にする 5 fragment 2 を part (CBOR の配列) にする 5 fragment 3 を part (CBOR の配列) にする 5 fragment 4 を part (CBOR の配列) にする 60 byte 85 array 04 seqLen 18 bd msgLen 189 1a 61 e6 e1 43 checksum 58 30 48 byte 01 seq fragment 1 02 seq fragment 2 03 seq fragment 3 04 seq fragment 4 6 Bytewords で 1 byte を 2 文字にする 末尾に part の CRC32 を 4 byte 足して 128 文字 85 LP 01 AD 04 AA 18 CS bd RY 1a CY 61 HS e6 VA e1 VY 43 FX 58 HD 30 DY 58 HD bb RK 85 LP 02 AO 04 AA 18 CS bd RY 1a CY 61 HS e6 VA e1 VY 43 FX 58 HD 30 DY 00 AE 00 AE 85 LP 03 AX 04 AA 18 CS bd RY 1a CY 61 HS e6 VA e1 VY 43 FX 58 HD 30 DY 00 AE e1 VY 85 LP 04 AA 04 AA 18 CS bd RY 1a CY 61 HS e6 VA e1 VY 43 FX 58 HD 30 DY 0b BD 07 AT … 7 UR にして QR へ 147 文字 · 英数字モード UR:CRYPTO-PSBT/1-4/LPADAACSRYCYHSV AVYFXHDDYHDRKJOJKIDJYZMADAEGMAOAEA EAEADAEAEAEAEAEAEAEAEAEAEAEAEAEAEA EAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEA EAEBEGMHPHP UR:CRYPTO-PSBT/2-4/LPAOAACSRYCYHSV AVYFXHDDYAEAEAEAEZMZMZMZMADAEVYYKA HAEAEAEAECMAEBBBYBYBYBYBYBYBYBYBYB YBYBYBYBYBYBYBYBYBYBYAEAEAEAEAEADA DCTCEBGEMEC UR:CRYPTO-PSBT/3-4/LPAXAACSRYCYHSV AVYFXHDDYAEVYYKAHAEAEAEAECMAEBBCPC PCPCPCPCPCPCPCPCPCPCPCPCPCPCPCPCPC PCPCPAMAOKKRNIYKBYTUORKPSGONBIDMDT OLTMWGOHPBG UR:CRYPTO-PSBT/4-4/LPAAAACSRYCYHSV AVYFXHDDYBDATAONDZTUYDPTODETAHKWZL YHPCMYACHMKCSTEGTQDFHGHAEAELAAEAEA ELAAEAEAELAAEAEAEAEATAEAEAEAEAEAEA EAEWPOTNYGR type · seq-seqLen · Bytewords (part + CRC32)
送金画面の入力とウォレットが足す UTXO・導出パスが PSBT になり、CBOR で包まれ、4 つに分けた part が Bytewords のテキストになって QR に入る。4 つの fragment を順に強調し、それぞれから作られる part・Bytewords・UR・QR を切り替えて見せる。値はすべてデモと同じもの

送信側では、次の順にデータを組み立てる。

  1. ウォレットに送金内容を入力する。 送金先・金額・手数料を指定する。使うコインと署名に使う鍵の情報は、ウォレットが選ぶ。
  2. ウォレットが PSBT を作る。 Creator が送金内容を反映した未署名の取引を作って global map に入れ、Updater が使うコインの情報 (witness UTXO) や鍵の導出パスを input map に加える。
  3. PSBT をバイト列にする。 この例の PSBT は 187 byte。UR にする前のデータはバイナリだ。
  4. CBOR で包む。 PSBT のバイト列を CBOR の byte string に入れ、全体の CRC32 を計算する。先頭の 58 bb は、長さ 187 byte の byte string を表す。
  5. QR に収まる大きさに分ける。 この例では 48 byte ずつに分ける。各断片にフレーム番号・総数・元データの長さ・全体の checksum を加えて CBOR 配列にしたものが part だ。
  6. Bytewords でテキストにする。 part の各 byte を 2 文字の英字に置き換え、part 自身の CRC32 を末尾に加える。英大文字と数字、/:- で表せるため、QR の英数字モードに収まる。
  7. UR 文字列を QR にする。 UR:CRYPTO-PSBT/1-4/ は、型が crypto-psbt で、4 枚中の 1 枚目であることを示す。

PSBT をバイト列で見る

送られるイメージはわたったけど、実際にバイナリにそんな内容入ってんの?と思ったので
バイナリの対応もみてみる。

ここからは、復元した PSBT のバイト列そのものを読む。
この記事で扱う BIP174 version 0 は、unsigned transaction を global map に持つ。

※BIP370 version 2 は unsigned transaction を持たず、後から input や output を追加できるよう、取引の要素をそれぞれの map に分けて持つ。

BIP370: PSBT Version 2PSBT_GLOBAL_UNSIGNED_TX を除き、取引の要素を各 map に分けて持つ版github.com

16 byte ごとに左から並べている。

PSBT は magic の後に key 長 → key → value 長 → value の組を並べた map が続き、key 長が 00 のところで map が終わる。
長さは CompactSize で表すが、この例では全部 253 未満なので 1 byte で済んでいる。

global map の中の unsigned transaction

PSBT v0 では、global map の key 00 の value が未署名の transaction になる。
このバイト列の内側は、transaction のルールでさらに分解される。

フィールド バイト列 意味
version 02 00 00 00 取引形式の version (little-endian)
input count 01 入力は 1 つ
prevout 00 × 32 · 00 00 00 00 参照する過去取引の ID (ダミー) と、その中の出力番号 0
scriptSig length 00 未署名なので空
sequence ff ff ff ff 入力の sequence 値
output count 01 出力は 1 つ
amount 00 e1 f5 05 00 00 00 00 100,000,000 sat (1 BTC)、little-endian
scriptPubKey 16 · 00 14 · 11 × 20 長さ 22 byte。P2WPKH の形をしたダミーの script
locktime 00 00 00 00 locktime なし

送金先と金額は unsigned transaction に記録されている。
署名端末が画面に出す「誰にいくら送るか」は、この transaction から読み取ったものになる。

input map と output map の情報

input map には、transaction 自体には含まれない署名用の情報が入る。
この例では 2 つの key を持たせている。

key type 01 witness UTXO の value は、金額 8 byte + script 長 + script。
この例では 1 BTC と P2WPKH 形式の script で、unsigned transaction の prevout が指す出力の中身を署名計算のために渡している。

key type 06 BIP32 derivation は、key に公開鍵、value に 4 byte の fingerprint と導出パスが入る。
例では m/84'/0'/0'/0/7。
秘密鍵ではなく、端末が署名に使う鍵を探すための導出経路だ。

パーサは、fingerprint が一致する導出情報を候補にし、P2WPKH か特定の Taproot 鍵パスとして扱えるかを確かめる。

PSBT v0 では、global map の unsigned transaction に記録された入力数・出力数に応じて、input map と output map が続く。
この例の output map は空で、終端の 00 だけだ。出力の金額と scriptPubKey は unsigned transaction に入っているため、output map に追加情報がなくても送金内容を読める。

署名端末は逆にたどって署名する

署名端末は QR を受け取り、次の順に PSBT を復元する。

  1. QR から UR 文字列を読み取り、Bytewords を byte に戻して、各 part の CRC32 を確かめる。
  2. フレーム番号を使って断片をそろえ、全体の checksum を確かめてから CBOR を外す。
  3. 元の PSBT のバイト列を取り出し、送金内容、使うコインの情報、鍵の導出パスを読む。QR を読み取る順番は問わない。
1 署名端末のカメラで QR を読む 1 枚 147 文字 · 4 枚 UR:CRYPTO-PSBT/1-4/LPADAACSRYCYHSV AVYFXHDDYHDRKJOJKIDJYZMADAEGMAOAEA EAEADAEAEAEAEAEAEAEAEAEAEAEAEAEAEA EAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEAEA EAEBEGMHPHP UR:CRYPTO-PSBT/2-4/LPAOAACSRYCYHSV AVYFXHDDYAEAEAEAEZMZMZMZMADAEVYYKA HAEAEAEAECMAEBBBYBYBYBYBYBYBYBYBYB YBYBYBYBYBYBYBYBYBYBYAEAEAEAEAEADA DCTCEBGEMEC UR:CRYPTO-PSBT/3-4/LPAXAACSRYCYHSV AVYFXHDDYAEVYYKAHAEAEAEAECMAEBBCPC PCPCPCPCPCPCPCPCPCPCPCPCPCPCPCPCPC PCPCPAMAOKKRNIYKBYTUORKPSGONBIDMDT OLTMWGOHPBG UR:CRYPTO-PSBT/4-4/LPAAAACSRYCYHSV AVYFXHDDYBDATAONDZTUYDPTODETAHKWZL YHPCMYACHMKCSTEGTQDFHGHAEAELAAEAEA ELAAEAEAELAAEAEAEAEATAEAEAEAEAEAEA EAEWPOTNYGR 2 Bytewords を byte に戻す 末尾 4 byte の CRC32 で壊れた QR を弾く LP 85 AD 01 AA 04 CS 18 RY bd CY 1a HS 61 VA e6 VY e1 FX 43 HD 58 DY 30 HD 58 RK bb LP 85 AO 02 AA 04 CS 18 RY bd CY 1a HS 61 VA e6 VY e1 FX 43 HD 58 DY 30 AE 00 AE 00 LP 85 AX 03 AA 04 CS 18 RY bd CY 1a HS 61 VA e6 VY e1 FX 43 HD 58 DY 30 AE 00 VY e1 LP 85 AA 04 AA 04 CS 18 RY bd CY 1a HS 61 VA e6 VY e1 FX 43 HD 58 DY 30 BD 0b AT 07 … 3 part の seq を見て fragment を並べる 1 / 4 受信 3 part の seq を見て fragment を並べる 2 / 4 受信 3 part の seq を見て fragment を並べる 3 / 4 受信 3 part の seq を見て fragment を並べる 4 / 4 受信 fragment 1 0–47 fragment 2 48–95 fragment 3 96–143 fragment 4 144–188 fragment 1 0–47 fragment 1 0–47 fragment 2 48–95 fragment 1 0–47 fragment 2 48–95 fragment 3 96–143 fragment 1 0–47 fragment 2 48–95 fragment 3 96–143 fragment 4 144–188 4 つないで CBOR を外す 全体の CRC32 61e6e143 を確かめる 58 bb PSBT 187 byte 5 PSBT を読む parser_parse magic 5 global map unsigned tx · 86 byte input map UTXO + derivation · 95 byte output 1 unsigned transaction 送金先・金額 witness UTXO 使うコインの金額と script BIP32 derivation fingerprint・導出パス 6 確かめて、署名する値と鍵を用意する 画面で確かめる 送金先 bc1qzyg3…zyg3h8ffkz 金額 1.00000000 BTC 手数料 入力の合計 − 出力の合計 OK を押す sighash (BIP143) unsigned tx と、使うコインの 金額・script をまとめて ハッシュした 32 byte。 署名はこの値に対して作る 金額が入るので、 偽の金額では署名が通らない 鍵を導出する シードフレーズ (端末で入力) 1 2 3 4 5 6 7 8 9 10 11 12 BIP39 → seed → BIP32 m/84'/0'/0'/0/7 fingerprint と script を照合 秘密鍵 7 ECDSA (secp256k1) で署名する sighash と秘密鍵から署名を作り、出す前に検証する libsecp256k1 (Bitcoin Core と同じ実装) · DER 70 byte + sighash type 01 = 71 byte 8 input map に部分署名を足す 294 byte magic 5 global map そのまま · 86 byte input map そのまま partial sig 02 + 公開鍵 → 署名 · 107 byte out 1 9 UR にして画面の QR へ 1 枚 139 文字 · 7 枚 UR:CRYPTO-PSBT/1-7/LPADATCFADDTCYG HGESFWDHDDNHKADDSJOJKIDJYZMADAEGMA OAEAEAEADAEAEAEAEAEAEAEAEAEAEAEAEA EAEAEAEAEAEAEAEAEAEAEAEAEAEAEWFTEP TDW UR:CRYPTO-PSBT/2-7/LPAOATCFADDTCYG HGESFWDHDDNAEAEAEAEAEAEAEAEAEAEZMZ MZMZMADAEVYYKAHAEAEAEAECMAEBBBYBYB YBYBYBYBYBYBYBYBYBYBYBYBYBYBYECONM UJE UR:CRYPTO-PSBT/3-7/LPAXATCFADDTCYG HGESFWDHDDNBYBYBYAEAEAEAEAEADADCTA EVYYKAHAEAEAEAECMAEBBCPCPCPCPCPCPC PCPCPCPCPCPCPCPCPCPCPCPCPCPCPRDRKK OFY UR:CRYPTO-PSBT/4-7/LPAAATCFADDTCYG HGESFWDHDDNAMAOKKRNIYKBYTUORKPSGON BIDMDTOLTBDATAONDZTUYDPTODETAHKWZL YHPCMYACHMKCSTEGTQDFHGHAEAELAKOHPO EHD UR:CRYPTO-PSBT/5-7/LPAHATCFADDTCYG HGESFWDHDDNAEAEAELAAEAEAELAAEAEAEA EATAEAEAECPAOAOKKRNIYKBYTUORKPSGON BIDMDTOLTBDATAONDZTUYDPTODETAKTLBC TPT UR:CRYPTO-PSBT/6-7/LPAMATCFADDTCYG HGESFWDHDDNHKWZLYHPCMYACHMKFLDYFYA OCXEOEOEOEOEOEOEOEOEOEOEOEOEOEOEOE OEOEOEOEOEOEOEOEOEOEOEOEOEOEOLAHDK GJE UR:CRYPTO-PSBT/7-7/LPATATCFADDTCYG HGESFWDHDDNEOEOAOCXFYFYFYFYFYFYFYF YFYFYFYFYFYFYFYFYFYFYFYFYFYFYFYFYF YFYFYFYFYFYFYFYADAEAEAEAEAEAEWTZTH YDR
署名端末は QR から PSBT を戻して読み、画面で確かめたうえで、sighash とシードフレーズから導出した鍵で ECDSA の署名を作り、input map に足して QR で返す。署名だけダミー

署名端末は、次の順に確認して署名する。

  1. 送金内容を確認する。 送金先・金額・手数料を画面に表示し、利用者が署名前に確認する。手数料は PSBT に含まれないため、使うコインの金額の合計から出力の合計を引いて端末が計算する。
  2. 署名対象の sighash を計算する。 SegWit v0 では BIP143 の手順で unsigned transaction と使うコインの金額・script をまとめてハッシュする。署名はこの 32 byte のハッシュに対して作る。PSBT の金額を偽っても、その金額を前提にした署名はネットワークで受理されない。
  3. 秘密鍵を導出する。 鍵は PSBT からは得られない。端末に入力したシードフレーズから BIP39 で seed を作り、PSBT に書かれた導出パス (m/84'/0'/0'/0/7) を BIP32 でたどって秘密鍵を導出する。master fingerprint が自分のものか、導出した公開鍵が使うコインの script と一致するかも確認する。
  4. 署名を作り、PSBT に加える。 sighash と秘密鍵を使い、secp256k1 の ECDSA で署名する。署名を検証してから、input map に部分署名 (PSBT_IN_PARTIAL_SIG、key は 02 + 公開鍵) として加える。この例では PSBT が 187 byte から 294 byte になり、7 枚の QR に分けてウォレットへ返る。

自前の署名モジュールで確認する

最近DIY Signerとして自分で署名モジュール jitsu-inを開発している。

habakan/jitsu-ingithub.com

jitsu-inはRaspberry Pi Pico 2などのマイコンで使えるようにしたことがメインだが、WebAssemblyベースなので、wasmランタイムがあればどこでも動く。
下のデモは parser.wasm で、QR から復元した PSBT を読み取る。アニメーション QR の受信状況、復元した PSBT の hex、署名端末が使う plan を確認できる。
本当に動いているので実際のカメラで 実際のunsigned psbtのURを読むこともできる。

この parser.wasm が読めるのは PSBT v0 で、サイズは 32,768 byte、input と output は各 16 まで。UR の分割は最大 1,024 枚まで扱える。PSBT v2 はまだ未対応だ。

4 枚目の QR を受け取ると 187 byte の PSBT が復元され、parser.wasm が plan を作る。plan には、送金内容や入力金額、鍵の導出パスなど、これまで見てきた情報が並ぶ。