ディスクリプターウォレットを使ってバックアップ機能付きビットコインウォレットを作ってみる

お知らせ
Table of Contents

はじめに

この記事では、バックアップ機能付きのアドレスの作り方、実装方法を紹介します。
この記事におけるバックアップ機能とは、自己管理しているウォレットの鍵にアクセスできなくなった場合に、一定時間が経つと別の鍵でアクセスができるような機能を指します。

概要

以下の2つの鍵を考えます。

  1. ユーザー管理鍵
    • これは単にユーザーが自己管理している鍵です。一般ユーザーによる鍵の自己管理は大変なので、紛失のおそれがあります。
    • 以下の説明では bobとします。
  2. 問い合わせに応じて使用される、サーバー管理の鍵
    • この鍵は、ビットコインウォレットへの入金後から一定時間が経過すると使用できるようになります。(なお OP_CSVの制約から最大455日までしかこの機能は使えず、それ以上長い期間の場合は OP_CLTVを使う必要があります1)
    • 以下の説明では aliceとします。

Taprootアドレスで実装する場合

以下、秘密鍵d(bob秘密鍵), 公開鍵Pとします。
Taprootでは、普段は鍵パスを使ってbobの鍵で署名を行えば、通常のシュノア署名送金と同じトランザクションに見える形で送金できるというメリットがあります。
アリスの鍵とtimelockを使いたい場合は、スクリプトパスを実行します。スクリプトは以下のようになります。以下では 相対タイムロック(OP_CHECKSEQUENCEVERIFY)を使っていますが、絶対タイムロック(OP_CHECKLOCKTIMEVERIFY)でも同様の実装が可能です。

scriptPubkey

<alice_key>
OP_CHECKSIGVERIFY
<sequence>
OP_CHECKSEQUENCEVERIFY

scriptSig(プログラムへの入力)は[alice_sig]になります。
また、全体の公開鍵 Q = tweak(P, S) = P + hash(P || S) G となります。
アリス、ボブがそれぞれ単一の鍵を持っている場合についてはこのようにして実装できますが、現実的にウォレットを実装するとなると、複数アドレスによる管理やお釣りアドレスが必要になってきます。
しかしこのままでは複数のalice_keyはどこからどういう手順で導出するのかなどが明確ではありません。複数アドレス管理をするために以下のような Miniscript, Descriptor Walletが定義されています。

Miniscript + Descriptor Wallet + Taprootを使った実装

Miniscriptは BIP-3792で定義されている簡易的なプログラミング言語です。ビットコインのスクリプトはスタックベースの言語ですが、その構文解析や合成を行いやすいように改良した言語(ビットコインのスクリプトのサブセット)がMiniscriptになります。

Descriptor Wallet

Decriptior Wallet(ディスクリプターウォレット)3を使うと、単一のシードから複数の鍵を導出し、それぞれの鍵を使ったスクリプトを構築することが簡単にできます。
内部的にMiniscriptの式の埋め込みがサポートされているので、同じビットコインスクリプトを使ったアドレスで、内部で使われている公開鍵だけが異なるものをテンプレートにしたがって複数生成することができます。

OP_CHECKSEQUENCEVERIFYの相対タイムロックについても older(n) でサポートされていますが(OP_CHECKLOCKTIMEVERIFY は after(n) )、以下miniscript, descriptor walletではパラメータ化した変数などは扱えないので、ここでは n = 10という具体的な値を入れて説明します。

Descriptor Walletを使用する際には以下の手順を踏みます。

  1. Wallet descriptor template (ウォレットディスクリプターテンプレート) という、鍵情報の入っていないテンプレートを用意
  2. Key information vector (鍵情報配列)という、具体的な鍵情報が入ったデータを用意
  3. 1のtemplateに2の配列を適用

例えば、具体的に上記のTaprootアドレスの場合のスクリプトと鍵を合わせたものをDescriptor Walletのテンプレートで表現すると以下のようになります。

スクリプトパスにアリス、鍵パスにボブの公開鍵が入るディスクリプタウォレットのテンプレート

tr(
  @0/**,
  and_v(v:pk(@1/**),older(10))
)

ここで @0/**, @1/** はそれぞれbob, aliceの鍵に相当するテンプレート埋め込み用マーカーであり4、具体的な拡張公開鍵xpubが与えられた時、その鍵から2種類の拡張公開鍵を生成するものになります。
2種類というのは受取用アドレスとお釣り用アドレスであり、上のbob, aliceの埋め込みマーカーは @0/<0;1>/*, @1/<0;1>/*のように書いても同じものを意味します(/**の記法は単なるシンタックスシュガーです)。
bobのxpubから受取用アドレスとお釣り用アドレスの鍵が生成され、aliceのxpubからも受取用アドレスとお釣り用アドレスの鍵が生成されます。

次に埋め込み用の鍵情報は以下のように書けます。見づらいので省略していますが、 xpub6ERA...RcELとあるところには xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL がはいります。
また、 xpub6Br3...XCebとあるところにはxpub6Br37sWxruYfT8ASpCjVHKGwgdnYFEn98DwiN76i2oyY6fgH1LAPmmDcF46xjxJr22gw4jmVjTE2E3URMnRPEPYyo1zoPSUba563ESMXCebがはいります。
d34db33f というのは xpub6ERA...RcEL が導出される前の、(ここには表記されていない)大元の拡張公開鍵のbip32フィンガープリント5に相当します。 xpub6ERA...RcELのフィンガープリントがd34db33fになっているわけではありません。

テンプレート埋め込み用の鍵情報配列

[
  "[d34db33f/44'/0'/0']xpub6ERA...RcEL",
  "[6738736c/44'/0'/0']xpub6Br3...XCeb"
]

これらのテンプレートと鍵情報配列から実際に鍵を埋め込んで生成したディスクリプターウォレットは以下のように表記できます。@0は鍵情報配列の0番目の要素(bobの鍵)に該当し、@1は1番目の要素(aliceの鍵)に該当します(0-indexです)。
構造がわかりやすいようにインデントをつけていますが、空白や改行が許可されているかは不明です。

テンプレートに鍵情報を埋め込んだディスクリプターウォレット

tr(
  [d34db33f/44'/0'/0']xpub6ERA...RcEL/<0;1>/*,
  and_v(v:pk([6738736c/44'/0'/0']xpub6Br3...XCeb/<0;1>/*),older(10))
)

ここからさらに、このウォレットに属する具体的なアドレスを導出したい場合には<0;1>(=お釣り用の内部アドレス(1)か受取用(0)か)の選択とアドレスインデックスの入力が必要になります。ここについてはbip-388で決められてはいないので実装次第になるところがありそうです。

例えば 受取用 = 0を選択して、アドレスインデックス7番のアドレスを生成する場合には、ディスクリプターウォレット全体で<0;1>のうち0を選択し、7という数字もアドレスインデックス全体で指定するように実装すると、今回のケースでは楽になるでしょう(<0;1>, <0;1>, *, * の4変数が2変数で済むため)。

実際に受取用 = 0を選択して、アドレスインデックス7番のアドレスを生成する場合には、以下のようなスクリプトで生成することになります。


03b6b15deb653384b8149ec9fff0586e27f6f61b12732d3377a6a9298a927a8696 ... "xpub6ERA...RcELを0/7のパスで拡張した公開鍵"

02c0cf0bf0f407c4b1c99623c2944a90fd704d9b3120c3975f24ebe966f16bed69 ... "xpub6Br3...XCebを0/7のパスで拡張した公開鍵"

tr(
  03b6b15deb653384b8149ec9fff0586e27f6f61b12732d3377a6a9298a927a8696,
  and_v(v:pk(02c0cf0bf0f407c4b1c99623c2944a90fd704d9b3120c3975f24ebe966f16bed69),older(10))
)

ディスクリプターウォレットの展開

実際に式を展開すると、tapscriptの中身
and_v(v:pk(@1/**),older(10))は以下のようなスクリプトになります。<alice_key>は上記の例で言えば<0;1>のうち0を選択し、インデックスとして7を選択した際に導出される公開鍵です ( = 02c0cf0bf0f407c4b1c99623c2944a90fd704d9b3120c3975f24ebe966f16bed69 )。

<alice_key> OP_CHECKSIGVERIFY 10 OP_CHECKSEQUENCEVERIFY

Miniscriptでは全てのbitcoin scriptの構文をサポートしているわけではなく、一部の解析可能な構文のみを採用しているため、出力されるスクリプトは限定的になります6

署名

ディスクリプターウォレットとminiscriptの全てのパターンで、対応する署名ができるというわけではないようですが7、今回くらい単純であれば以下に示すように bitcoinerlab/descriptors ライブラリを使って署名できます。

TypeScriptの実装(P2WSHで実装する場合)

bitcoinerlab/descriptors ライブラリを使った例

bitcoinerlab/descriptorsはnpmで配布されているライブラリで、限定的ですがdescriptorを使った署名操作ができます。なおこのライブラリを実際に使って作られたウォレットとして Rewind Bitcoin8が存在します。

ウォレット作成

基本的にはこちら9のガイド通りに進めるだけです。
ただし注意点として、こちらのガイドでは上記のようにtaprootのkeyパス、scriptパスを分けるのではなく、segwit v1のscript(p2wsh内)で実装しています。

まず最初にolderを引数としてポリシー(鍵情報の入っていないテンプレート)を返却する関数を用意しておきます。emergencyKeyがユーザー管理鍵でbobに該当し、unvaultKeyがサーバー管理でaliceに該当します。

const POLICY = (after: number) =>
  `or(pk(@emergencyKey),and(pk(@unvaultKey),older(${older})))`;

上の方で compilePolicy をインポートしておくと

import { compilePolicy } from '@bitcoinerlab/miniscript';

ポリシー文字列(ウォレットディスクリプターテンプレート)をコンパイルできます。

const { miniscript, issane } = compilePolicy(POLICY(after));

ここで miniscript は ただの文字列です。
次に、miniscript文字列を含むようなディスクリプターウォレットの一部としてminiscriptを埋め込み、具体的な鍵でテンプレートの中身を置換しています。Output もライブラリに含まれるクラスです。

const wshDescriptor = `wsh(${miniscript
      .replace(
        '@unvaultKey',
        descriptors.keyExpressionBIP32({
          masterNode: unvaultMasterNode,
          originPath: WSH_ORIGIN_PATH,
          keyPath: WSH_KEY_PATH
        })
      )
      .replace('@emergencyKey', emergencyPair.publicKey.toString('hex'))})`;
const wshOutput = new Output({
      descriptor: wshDescriptor,
      network,
      signersPubKeys: [emergency ? emergencyPair.publicKey : unvaultKey]
    });
const wshAddress = wshOutput.getAddress();

署名

署名のときは、Psbt ( bitcoinjs-lib のクラス)を使います。

const psbt = new Psbt({ network });
const inputFinalizer = wshOutput.updatePsbtAsInput({ psbt, txHex, vout: utxo[0].vout });

このPSBTに宛先を追加した後、以下のように署名を実行します。

descriptors.signers.signECPair({ psbt, ecpair: emergencyPair });
inputFinalizer({ psbt });

完成したPSBTは署名済みのトランザクションとして、bitcoinjs-libと同様に扱えます。手元で試してみると、問題なくブロードキャストできました。

おまけ: 非Taprootアドレスで実装するメリット

Taprootアドレスで実装するのは手数料上も有利で、鍵パスを使うことでオンチェーン上のプライバシーも保たれる理想的な方法ですが、シュノア署名の公開鍵が直接オンチェーン上で見えてしまうという問題があります。
こちらは通常は問題にならないのですが、今回のバックアップ機能という条件の場合、数年間資金がロックされている間に量子コンピュータが開発され、公開鍵に対応する秘密鍵が生成できてしまうようなケースが想定されます。特にロック期間を長く取る場合には量子コンピュータの存在が脅威であると判断される可能性が高くなります。

一方で、非Taprootアドレス(P2SH, Pay to Script Hash or P2WSH, Pay to Witness Script Hash)で実装する場合にはスクリプト本体はハッシュ化された状態でオンチェーン上に保存されるため10のプロトコルなどによって回収可能になる可能性があります。

参考文献


  1. Bitcoin’s Time Locks https://medium.com/summa-technology/bitcoins-time-locks-27e0c362d7a1 

  2. This document specifies Miniscript, a language for writing (a subset of) Bitcoin Scripts in a structured way, enabling analysis, composition, generic signing and more. https://github.com/bitcoin/bips/blob/master/bip-0379.md 

  3. Descriptor wallet で Multisig https://zenn.dev/kanna/articles/b1112ee16d130b 

  4. Wallet descriptor template https://github.com/bitcoin/bips/blob/master/bip-0388.mediawiki#wallet-descriptor-template 

  5. Key identifiers https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki#key-identifiers 

  6. Descriptors cannot describe all arbitrary scripts, only a subset with specific properties that make them analyzable. You must use Miniscript for those scripts. https://bitcointalk.org/index.php?topic=5489063.msg63817554#msg63817554 

  7. Not all Miniscript can be signed yet. https://bitcointalk.org/index.php?topic=5489063.msg63819767#msg63819767 

  8. Rewind Bitcoin https://rewindbitcoin.com/ 

  9. Creating a Timelocked Vault with Miniscript https://bitcoinerlab.com/guides/miniscript-vault 

  10. 既存のUTXOを量子コンピューター登場後でも安全に使用するための提案 https://techmedia-think.hatenablog.com/entry/2025/04/07/184032 

タイトルとURLをコピーしました