Develop with pleasure!

福岡でCloudとかBlockchainとか。

testnetで発見されたNeutrinoのTaproot関連の問題と軽量クライアントの課題

少し前に報告されたtestnetで発生したNeutrinoのフィルタ処理の問題について↓

https://lists.linuxfoundation.org/pipermail/bitcoin-dev/2021-November/019589.html

Neutrinoの位置付け

LNノード実装の1つであるLNDのブロックチェーンのバックエンドの連携先の1つがNeutrinoで、LNDにバンドルされている。現状LNDは以下のバックエンドが選択できる。

この内、Neutrinoは軽量クライアントとして位置付けられる。

軽量クライアントは、ブロックチェーン全体をダウンロードすることなく、ウォレットに関連するブロック、Txのみをダウンロードすることで、ノードのリソースコストを最小限に抑え、IoTデバイススマートフォンなどの低リソース環境でも動作させることができる。

もともとBitcoinにはBIP-16で定義されたBloom Filterベースのフィルターの仕組みを使ったSPVの仕様があったが、このプロトコルはフルノードへのトラストがネックになる。LNなどのペイメントチャネルでは、自分に関連するTxをオンチェーンで監視する必要があり、正しく検知できないと資金を失うリスクがあるため、このトラストポイントは最小化したい。

そこで、フルノードへのトラストを最小限にし、プライバシーも向上させるように新しく提案された軽量クライアント向けの仕様がBIP-157/BIP158↓

techmedia-think.hatenablog.com

techmedia-think.hatenablog.com

で、このBIPの前身となったのがNeutrino。Neutrinoは実装でもあるものの、Neutrinoプロトコルと呼ばれるのは、このため。

問題の原因

↑のBIP-157/BIP158は、既にBitcoin Coreにも実装されているけど、今回問題となったのはNeutrinoの実装の問題。

BIP-157/BIP158では、フルノードが各ブロック毎に、ブロック内の全トランザクションのインプット(正確にはインプットが参照する前のトランザクションのアウトプット)とアウトプットからフィルターを作成し、そのフィルターをP2Pメッセージで共有するようになっている。

軽量クライアントはブロックヘッダーのダウンロードに加えて、これらのフィルターのヘッダーおよびフィルタ自体をダウンロードする。そして、フィルタに対して自身に関連するトランザクションが含まれているかどうかを軽量クライアント自身がチェックする。この辺りのチェックの仕組みは↓

techmedia-think.hatenablog.com

今回問題になったのは、各ピアから入手したフィルターが競合した場合に、どのフィルターが正しいかチェックする際の実装。

Neutrinoでは競合するフィルターが見つかった場合、対象となるブロックを実際にダウンロードして、フィルターをチェックするようになっている。この振る舞い自体は問題なく、verification.cppに以下のチェックが実装されている。

  1. ブロック内の全TxのアウトプットのscriptPubkeyがフィルタにマッチするかどうか。
  2. ブロック内の全Txのインプットが参照するUTXOのscriptPubkeyがフィルタにマッチするかどうか。

このうち1についてはブロック内のトランザクションにscriptPubkeyがあるので問題ないけど、2のscriptPubkeyをどこから取得するのか?というのが課題になる。Neutrinoは軽量クライアントでブロックチェーン自体のデータを持っていないため、2のscriptPubkeyを持っていない。

そこで、NeutrinoはインプットのsciptSig/witnessのデータからscriptPubkeyを計算するというヒューリスティックを採用していた。計算ロジックは↓

  1. witnessにデータがある=Segwit UTXOである。
    • witnessのアイテムが2つで、最後の要素のサイズが公開鍵(33バイト)と等しい場合、P2WPKHと判断して、公開鍵からP2WPKHのscriptPubkeyを計算する。
    • それ以外の場合、witnessの最後のデータをwitness scriptとして、P2WSHのscriptPubkeyを計算する。
  2. witnessではなく、scriptSigにデータがある=非Segwit UTXOである。
    • scriptSigのデータサイズが署名+圧縮公開鍵の取る範囲内であれば、P2PKHと判断して、公開鍵からP2PKHのscriptPubkeyを計算する。
    • それ以外の場合、P2SHと判断して、scriptSigのデータからredeem scriptを取得しP2SHのscriptPubkeyを計算する。

この内、1のロジックがTaprootに対応しておらず、TaprootのwitnessデータはすべてP2WSHとして判断されてしまい、誤ったscriptPubkeyが計算されてしまう。この結果、インプットが参照する正しいscriptPubkeyとは異なるscriptPubkeyが導出され、それをフィルタにかけるので、本来正しいはずのフィルターを不正なフィルターと判断してまう。

※ コードみた感じだと、P2PKHで非圧縮公開鍵使った場合もフィルターにアンマッチしそうなんだけど、どうなんだろう?

対応

直近の対応

直近の対応としては、↑のインプットのフィルタのチェックはするものの、ミスマッチが発生してもエラーログは吐くが不正なフィルターとは判定しないように修正されたみたい↓

https://github.com/lightninglabs/neutrino/pull/234/files#diff-86f3698bdc171fb0271f7ab951325ba3e6bf24aee52c7e9d645a1cd7040e758d

これ対応が入ったLND v0.13.4がリリースされている。

今後の対応

↑で不正なブロック判定はされなくなったものの、じゃあこの振る舞いを今後どうする?という課題が残る。これは別にNeutrinoに限った話ではなく、軽量クライアント全般に言えることだと思う(複数のピアから入手しているので、その中で一致する数の多いフィルターを正とするというアプローチもあると思うけど)。

そこで対策として挙げられているのが↓

  • 過去のヘッダーの取り扱いに関する緩和策としては、Neutrinoに10万ブロック毎にフィルターのヘッダーをハードコードしてチェックポイントとする(これは既に実装されている)。
  • ルノードからブロックのundoデータを入手できる新しいP2Pメッセージを用意する。ブロックのundoデータには、そのブロックで使用されたUTXOの情報が含まれるので、これをP2Pメッセージで入手できるようにすることで、インプットが参照するscriptPubkeyを補完する。
  • 複数のブロックにまたがるフィルターを作成する
  • マイナーに自身がマイニングしたブロックのフィルターへのコミットを求める。
  • Taprootの構成要素である内部キー/外部キー、Control Block、マークルルート、annexなどを照会可能な新しいフィルタータイプを追加する。

など。

GraftrootとG'rootを組み合わせたEntroot

Pieter WuilleとAnthony Townsの議論から、GraftrootとG'rootを組み合わせたEntrootというプロトコルが公開されてる↓

https://gist.github.com/sipa/ca1502f8465d0d5032d9dd2465f32603

Graftrootとは?

Graftrootは、Gregory MaxwellがTaprootを発表した後に追加で提案されたプロトコル

Taprootは、

  • (集約公開鍵である場合もある)単一の公開鍵を使ったkey-pathによる支払い
  • アンロック条件がエンコードされたマークルツリーを利用するscript-pathによる支払い

をサポートしているが、これはいずれも予め決められたアンロック条件を使った支払いになる。

Graftrootは、key-pathの鍵を使って、任意のスクリプトに署名し、そのスクリプトと署名を提供することで、後から任意の支払い条件を追加できるようにするというプロトコル

G'rootとは?

G'rootについては、まず先日書いた投稿を参照↓

techmedia-think.hatenablog.com

Condition Point

G'rootでは、楕円曲線の2つめのジェネレーターG2を利用したPedersen Commitment

Q = P + H(s)G2

を利用していたが、ここではそれを少し改良して、G2の代わりにハッシュ値を誰もその離散対数を知らない曲線上の点にマッピングするハッシュ関数hash-to-curve(Hc())を使用する↓

Q = P + Hc(s)

hash-to-curveの一番簡単な実装は、生成したハッシュ値楕円曲線のx座標として曲線上の点に変換するというもの(該当するx座標の点がない場合は、値をインクリメントしていく)。G2でなくhash-to-curve使うのは楕円曲線の乗算よりも高速だから。

Entroot

↑のEntrootの説明では、G'rootを再公式化して説明しており、このCondition Pointの使用や、支払いポリシーを3つのノードタイプを持つツリーとして定義しており、基本的に各Condition Pointと支払いポリシーのツリーが

  • 単一の公開鍵によるアンロック
  • 単一の公開鍵とスクリプトによるアンロック
  • (単一の公開鍵とスクリプトを開示した上で)リンクしている別のCondition Pointによるアンロック

を表現できるようになっている。

Entrootは上記の評価ルールに4つめのアンロック条件=Graftrootの評価ロジックを追加しようというもの。

G'rootでは、スタックで公開されたアイテムを使って、hash-to-curveの要素を削除する。つまりQ - Hc(s)して出てきたPに対する署名検証を行う。そして、スクリプトsの評価を行う。

この時、署名対象のメッセージは、通常その支払いトランザクションのsig hashであるが、もしスタックに別のCondition Point(Qgraft)が含まれている場合、かつそれを署名対象のメッセージとしてPに対して有効な署名が提供されていた場合、Condition Point(Qgraft)が次の評価ポイントとして評価されるようになる。

つまり、任意の評価条件=Condition Pointに対して、現在のアンロック条件によって有効な署名が提供されていれば、それをアンロック条件として評価するルールを追加しようというもの。

G'rootとの組み合わせで変わること

元々のGraftrootの提案では、key-pathの鍵を使って署名して任意のアンロック条件を追加するというものだった。

しかし、G'rootのロジックに加えると、key-pathの鍵に限定されずに、G'rootのアンロックブランチのいずれかの条件を満たせれば、誰でも任意のアンロック条件を追加できるようになる。元のTaprootの文脈でいうと、key-pathだけじゃなくてscript-pathのどの条件ブランチを使っても新たなアンロック条件を追加できるということ。

Generalized taproot(G'root)

Generalized taproot(G'root)は、2018年にAnthony Townsによって提案されたPedersen Commitmentを使ってTaprootを実現するプロトコルの提案。

Taprootのscript-pathは、アンロック条件をマークルツリーにエンコードするMAST(Merklized Alternative Script Trees)という仕組みに依存しているが、G'rootは同様のことをマークルツリーを使わずに、Pedersen Commitmentで実現する。

Taprootのロックスクリプトのwitness programは、

Q = P + H(P, s)G

という公開鍵で構成される。この内Pは内部キー、sスクリプトツリーのルートハッシュから導出した値が含まれる。

key-pathによるアンロックは公開鍵Qに対して有効な署名を提供し、script-pathによるアンロックはスクリプトツリー内のスクリプトとそれが含まれるルートまでのマークルパスとPと、スクリプトを満たすwitnessが提供される。

G'rootの構成

Taprootの場合sを導出するのに、各スクリプト条件をリーフノードとしたスクリプトツリーを構成するが、G'rootではツリーを構成するのではなく、各条件毎に以下のようなPedersen Commitmentを計算し、それを連鎖させていく。

A' = aG + sG2 + H(aG + sG2, B')G

G2Gとは別の楕円曲線上のジェネレーター、B'は別のアンロック条件で構成されるPedersen Commitment。

A'にロックされたコインを使用する方法は、

  1. A'にスクリプト条件がない場合=つまりs = 0の場合のアンロック
    A' = aG + H(aG, B')Gとなるため、a + H(aG, B')秘密鍵として公開鍵A'に対して有効な署名を直接提供する。これはs = 0の場合のみ成立する。s != 0の場合、G2が残るので有効な署名は作れない。
  2. スクリプトsを使ってアンロックする場合
    sを公開し、公開鍵A' - sG2に対して有効な署名と、スクリプトsを満たすwitnessを提供する。公開されたsによりA' - sG2でG2が無くなるので、a + H(aG + sG2, B')秘密鍵として有効な署名を作成することができる。
  3. 別のPedersen Commitment B'の条件を使ってアンロックする場合
    aG + sG2B'を公開し、B'を満たすwitnessを提供する。

もう少し具体的な例を使うと、例えば以下の複数のアンロック条件を持つG'rootを構成する場合、

各条件ごとにに以下のようなPedersen Commitmentを作成する。

  • D' = z
  • C' = cG + yG2 + H(cG + yG2, D')G
  • B' = bG + xG2 + H(bG + xG2, C')G
  • A' = aG + H(aG, B')G

そしてG'rootのscriptPubkeyはA'となる。A'B'C'D'へのアンロック条件の連鎖を構成し、TaprootのMAST構造を代替する。

A'をアンロックするためには、

  • A'でアンロックする場合は、秘密鍵a + H(aG, B')を使った署名を
  • B'でアンロックする場合は、aGB'xを公開して、秘密鍵b + H(bG + xG2, C')を使った署名とスクリプトxを満たすwitnessを
  • C'でアンロックする場合は、aGB'bG + xG2C'yを公開して、秘密鍵c + H(cG + yG2, D')を使った署名とスクリプトyを満たすwitnessを
  • D'でアンロックする場合は、aGB'bG + xG2C'スクリプトスクリプトからzまでのマークルパスを公開し、スクリプトのwitnessを

提供する。使用確率の高いアンロック条件をA'に近い位置に配置するのが、効率的。

zの部分は、スクリプトツリーを使ってるので、MASTを使わないという訳ではない。

c-lightningをRaspberry PiにセットアップしてUmbrelのBitcoin Coreと連携してみる

Diamond Handsに参加するのにUmbrel on Raspberry Pi 4を使ってフルノードのBitcoin CoreとLightningノードのLNDを家庭内LAN環境で稼働し始めたんだけど、1台Raspberry Pi 3が余っていたので、それを使って追加でc-lightningのノードを立ててみた。

f:id:techmedia-think:20211027153617j:plain

Umbrelの設定変更

Umbrelには、1TBの外部SSDをつないで、フルノードを動かしているけど、c-lightningのノード側でもフルノードを稼働するのは冗長的でスペック的にもしんどそうなので、c-lightningが連携するフルノードはUmbrel上で稼働しているものにしたい。

Umbrelでは、Bitcoin CoreやLNDを含め各ソフトウェアはDockerコンテナで起動するようになっていて、これらのアプリケーションの構成は、/home/umbrel/umbrel/docker-compose.ymlに定義されてる。

まず、同一LAN内にあるRaspberry Piからumbrel上のBitcoin CoreへのRPCを叩けるようにするため、docker-compose.ymlbitcoinの設定で、RPCのポートを公開するよう編集する↓

  bitcoin:
        container_name: bitcoin
        image: lncm/bitcoind:v22.0@sha256:37a1adb29b3abc9f972f0d981f45e41e5fca2e22816a023faa9fdc0084aa4507
        depends_on: [ tor, manager, nginx ]
        volumes:
            - ${PWD}/bitcoin:/data/.bitcoin
        restart: on-failure
        stop_grace_period: 15m30s
        ports:
            - "$BITCOIN_P2P_PORT:$BITCOIN_P2P_PORT"
            - "$BITCOIN_RPC_PORT:$BITCOIN_RPC_PORT" # ←この行を追加
        networks:
            default:
                ipv4_address: $BITCOIN_IP

続いて、Bitcoin Coreの設定ファイル/home/umbrel/bitcoin/bitcoin.confを編集し、LAN内のマシンからRPCアクセスを許可するためのIPの設定を追加する↓

rpcallowip=192.168.50.1/24 # 自分の環境に合わせてIPの範囲を指定

↑の設定を変更して、Umbrelを再起動すれば、LAN内のマシンからRPCアクセスができるようになる。

bitcoin.confdocker-compose.ymlに加えた変更は、Umbrelのバージョンを上げると上書きされて消えてしまうと思うので要注意。

Raspberry Piのセットアップ

ここから64 bitのRaspi osをダウンロードする(まだ公式には64 bit版は公式にリストされてないのね)。ダウンロードしたらRaspberry Pi Imagerを使ってSDカードにOSイメージを書き込む。

デフォだとSSHは無効化されているので、書き込みが終わったディスクのルートディレクトリ(/boot直下)にsshという名前の空ファイルを作っておく。これでSSH接続できるようになる(モニターやキーボード繋げてセットアップするのが面倒なので)。

LANケーブルを繋いで電源を入れると、あとはSSHでつないで作業できる。Raspberry Piに割り当てられたIPは、arp-scanとかで調べる。

$ sudo arp-scan -l
...
192.168.50.227  b8:27:eb:24:67:e0   Raspberry Pi Foundation

初期ユーザーとパスワードはpi/raspberry

Dockerのセットアップ

Umbrelと同様、Raspberry Pi上にDockerでノードを起動するようにするため、Docker環境をインストールしておく。

まずリポジトリの設定↓

$ sudo apt-get update
$ sudo apt-get install \
    ca-certificates \
    curl \
    gnupg \
    lsb-release
$ curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
$ echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian \
  $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

続いてDockerのインストール↓

$ sudo apt-get update
$ sudo apt-get install docker-ce docker-ce-cli containerd.io

sudoしなくても実行できるようにpiユーザーをdockerグループに入れておく↓

$ sudo usermod -aG docker $USER

続いて、Docker Composeのインストール。1.x系はリポジトリにARM用のバイナリが公開されてないので、pipでインストールする。

$ sudo apt-get install libffi-dev
$ sudo pip3 install docker-compose

c-lightningのセットアップ

c-lightningとTorをRapberry Pi上で動かすためのdocker-compose.ymlを用意したので↓を使ってLNノードをセットアップする。

https://github.com/azuchi/c-lightning-on-raspberry-pi

Rapberry Pi上で↑のリポジトリをクローンする。

$ git clone https://github.com/azuchi/c-lightning-on-raspberry-pi.git

続いて、初期セットアップ。

$ cd c-lightning-on-raspberry-pi.git
$ cp cp .env.sample .env

.envの設定値を自分の環境に合わせて設定する↓

# Bitcoin
BITCOIN_NETWORK=bitcoin
BITCOIN_RPC_HOST=<ロカールネットワークでumbrelが動作しているマシンのIP>
BITCOIN_RPC_PORT=8332
BITCOIN_RPC_USER=<umbre上のbitcoindのRPCユーザー>
BITCOIN_RPC_PASSWORD=<umbre上のbitcoindのRPCパスワード>

# c-lightning
LIGHTNING_ALIAS=<LNノードのエイリアス>
LIGHTNING_RGB=<LNノードのカラー(Hex値)>

続いて初期化処理のシェルを実行↓

$ ./setup.sh

一通り設定が終わったら、c-lightningとtorのサービスを起動する↓

$ docker-compose up -d

と、これでumbrel上のBitcoin Coreを連携するRaspberry Pi上にc-lightningが稼働し始める。

lighting-cliの実行

コンテナ上で実行されているc-lightiningに対してlightning-cliの実行がでるよう、binディレクトリ以下にlightning-cliを用意している。

$ cd bin
$ ./lightning-cli help

のように実行できる。このファイル自体は、内部でコンテナで動作するc-lightningに対してdocker-compose execしているだけ。

補足

セットアップは↑で終わりだけど、今回↑のdocker-composeを構成するにあたり、c-lightningのDockerイメージを新たに作成した↓

※ 作成したイメージは、DockerHubで公開しているので、基本的に↑のdokcer-composeを使うのであれば、以下の作業をする必要はない。

c-lightningのDockerイメージの作成

lndについては、Lightning LabsがDocker Hub上にイメージを公開しているc-lightningもElements Projectでコンテナイメージが公開されているんだけど、残念ながらamd64アーキテクチャのイメージしかなく、arm64のイメージは公開されていない。

Raspberry Piで使用する場合、armアーキテクチャに対応したイメージが必要なので、まずはc-lightningのarm64用のDockerイメージを作成する。c-lightningのリポジトリにもarm64用のイメージのビルドファイルがある↓

https://github.com/ElementsProject/lightning/blob/v0.10.1/contrib/linuxarm64v8.Dockerfile

ので、これを使ってビルドする。今回はリポジトリをcloneして、タグv0.10.1のコードを使ってビルドする。

ちなみにamd64環境でビルドする際は、事前に以下を実行しておく必要がある。

$ docker run --rm --privileged multiarch/qemu-user-static:register --reset

続いて、イメージのビルド

$ docker build -t azuchi/lightningd:v0.10.1 -f contrib/linuxarm64v8.Dockerfile .

DockerHubにプッシュしておく↓

$ docker push azuchi/lightningd:v0.10.1

以降は、Docker Hubから入手可能↓

https://hub.docker.com/repository/docker/azuchi/lightningd

Inherited IDを使ったトランザクションの参照と新しいチャネルプロトコル2Stage

先月、Bitcoin-DevメーリングリストにJeremyによって代理投稿された、匿名の開発者(John Law)による投稿では↓

https://lists.linuxfoundation.org/pipermail/bitcoin-dev/2021-September/019470.html

現在提案されれているBIP-118(anyprevout)に代わり、Inherited IDという新しいトランザクションの識別子を導入し、eltooに代わる新しいチャネルプロトコルを提案している。具体的なペーパーは↓

https://github.com/JohnLaw2/btc-iids/raw/main/iids14.pdf

BIP-118は、トランザクション署名時に、署名対象のメッセージからインプットが参照するOutPointを除外することで、署名後に(同じscriptPubkeyとvalueを持つ)インプットを切り替えることができるようにするというもの。これを利用すると、現在のLNチャネルのペナルティの仕組みを廃止し、古いステートのコミットメントTxがブロードキャストされても後から最新のコミットメントTxに置き換えることができるeltooスタイルのLNチャネルが可能になる。この詳しい仕組みについては↓

techmedia-think.hatenablog.com

Inherited IDとは?

トランザクションのインプットでどのUTXOを使用するか指定する際に用いられるのがOutPointで、これはTXIDとそのトランザクションのアウトプットのインデックスで構成されている。TXIDはトランザクションハッシュ値である(正確にはハッシュ値エンディアンを逆にした値)。Bitcoinではトランザクションを指定する際に、このTXIDを使用している。

↑のペーパーでは、TXIDに代わってInherited ID(IID)という識別子を利用しようと提案している。このInherited IDというのは、親トランザクションの先頭のインプットのOutPointのハッシュ値である。例えば以下のような親子関係のあるトランザクションTx A, Tx B, Tx Cがあった場合、

f:id:techmedia-think:20211010132735p:plain

Tx Bのインプットが参照するTx AのUTXOは、通常OutPoint(TXID, index)で参照されるが、このTXIDの部分をIID=Tx Aの先頭のインプットが参照するOutPointのハッシュ=H(OutPoint(XXX, 1))に置き換える。さらのTx Cが参照するTx BのUTXOを参照する際のIIDは、Tx Bの先頭のインプットのOutPointのハッシュ、つまりH(OutPoint(H(OutPoint(XXX, 1), 0)になる。

このIIDを使ってトランザクションを参照すると、インプットが参照先のトランザクションのハッシュにコミットする必要がなくなる。つまり、インプットがUTXOを参照する際に、そのトランザクションのTXIDを知る必要がない。

これはanyprevoutと同じ効果を生む。ただし、anyprevoutでは同じscriptPubkeyとvalueを持つUTXOであれば何でも切替可能であったのに対し、IIDは親のインプットのOutPointにコミットしているため、anyprevoutのような切り替え(フローティング・トランザクション)はできない。anyprevoutのフローティングの仕組みは、同じ構成のアウトプットの作成により意図しないフローティングを生み出すのではないかという懸念があるが、IIDの場合その点のリスクはなくなる。

ペーパーでは、新たにsegwit version 2のアウトプットに対してこのIIDの適用を提案している。それ以外の従来のscriptPubkeyを持つトランザクションやコインベーストランザクションについては、そのトランザクションのIID = TXIDとするとしている。つまり↑のA, B, Cの関係で言うと、Aは非IIDトランザクションで、そのインプットは従来のTXIDを使ってトランザクションを参照している(OutPoint(XXX, 1))。

※ ちなみにこをそのまま実装するとハードフォークになる。ソフトフォークで実現するための仕組みもペーパーのAppendixに掲載されているみたい。

2Stage Channel

IIDによって、インプットが参照先のTXIDにコミットしないことは分かったが、フローティングはできないので、eltooプロトコルを実装することはできない。そのため既存のLightningチャネルとeltooを組み合わせたような2Stageと呼ばれる新しいチャネルプロトコルを提案している。トランザクションのステート管理は、nLocktimeを使用するeltooスタイルを採用しているので、プロトコルの理解のためには、事前にeltooの仕組みを理解しておいた方がいい(参考:GBEC動画ブログ記事)。

2Stageプロトコルで構成されるチャネルには、以下の種類のトランザクションが登場する。

  • Funding Tx
    両者のマルチシグにチャネルの資金をデポジットするトランザクション
  • Update Tx
    Funding Txをインプットするトランザクションで、両者のチャネル残高の配分を更新するトランザクション
  • Settlement Tx
    Update Txをインプットとして、チャネルをクローズする=チャネル残高を両者のアドレス宛に決済するトランザクションで、Update Txをブロードキャストしたユーザーがブロードキャスト可能。
  • Remedy Tx
    古いステートのUpdate Txがブロードキャストされた際に、Update Txをインプットとして資金を取り戻すためのトランザクションで、Update Txをブロードキャストしたのとは別のユーザーがブロードキャスト可能。
  • Closing Tx
    チャネル参加者が協力して最終残高でチャネルをクローズする際のトランザクション

Settlement TxとRedemy Txは競合関係にある。古いステートのUpdate Txがブロードキャストされた際に、Settlement Txでそれを回収する場合、タイムロックによる遅延が設定されている。つまり、Update Txがブロードキャストされると、それをインプットとするSettlement Txが有効になるまで遅延時間が設定されており、その間に不正されていればRemedy Txで相手が資金を取り戻せるという仕組み。

Funding Txに対して、Update Txをブロードキャストする第一段階と、Settlement TxおよびRemedy Txのどちらかがオンチェーンで有効にる第二段階で構成されることから2Stageプロトコルという名前になってるみたい。

トランザクションの構成を簡単に図示すると以下のようになる↓

f:id:techmedia-think:20211011103551p:plain

またメインのコントラクトとなるUpdate TxのscriptPubkeyは以下のようなものになる:

<state X> OP_CHECKLOCKTIMEVERIFY OP_DROP //子トランザクションがnLocktime ≥ state Xであることをチェック
OP_IF // Settlement Tx用のアンロック条件
  OP_DUP <pubkey A&B> OP_EQUALVERIFY OP_CHECKSIG // 公開鍵A&Bに対する署名検証
OP_ELSE // Y≧XのRedemy Tx用のアンロック条件
  OP_CODESEPARATOR // 以下の署名検証が上記の<state X>の値をカバーするのを防ぎ、Redemy Txの署名がXの値に依存しないようにする。
  OP_DUP <pubkey A&B> OP_EQUALVERIFY OP_CHECKSIG // 公開鍵A&Bに対する署名検証
OP_ENDIF
<OP_0 if Alice's, OP_1 if Bob's> // アリスのTxなら0をボブのTxなら1をプシュ。これはアリスのUpdate Txの子の署名とボブのUpdate Txの子の署名を区別するために含まれている
OP_DROP // 署名を区別するためだけに含まれていた、直前の0 or 1をスタックから削除

この2Stageチャネルには、以下の特徴がある。

  • ステート:
    オフチェーンのステートは、Update Txに設定され、それを使用するSettlement TxおよびRedemy TxのnLocktimeにはそれぞれのそのステート番号が設定される。これにより、ブロードキャストされたUpdate Txのステート番号より大きな番号のステートのSettlement TxおよびRedemy Txしかブロードキャストできない。この仕組みはeltooで採用されているのと似た仕組み。
  • Txの非対称性:
    eltooでは、両者が保持するTxには対称性があったが、2StageではUpdate Tx、Settlement Tx、Redemy Txに小さな違いがある。↑の図では簡易的に1つのステートに対して1セットの各Txしか書いてないが、実際には小さな違いのある各Txのセットが両者分ある。
  • ブロードキャスト可能なTx:
    基本的にUpdate Txをブロードキャストしたユーザーは、Settlement Txのみをブロードキャストでき、もう一方のユーザーはそれに対してRedemy Txのみをブロードキャストできるようになっている。
    • アリスがアリスのUpdate Txをブロードキャストした場合
      • Settlement Txをブロードキャストできるのはアリスのみ
      • Redemy Txをブロードキャストできるのはボブのみ
    • ボブがボブUpdate Txをブロードキャストした場合
      • Settlement Txをブロードキャストできるのはボブのみ
      • Redemy Txをブロードキャストできるのはアリスのみ
  • 古いUpdate TxのブロードキャストとIID:
    この場合、Update TxがIID Txになっているため、ブロードキャストされたユーザーは自分が持っているRedemy Txの内、ブロードキャストされたステートより、新しいステートのRedemy Txをピックアップしてブロードキャストする。この時、Redemy TxはIIDを使ってUpdate TxのUTXOの参照しているため、参照先のUpdate Txを切り替えることができる。
  • OP_CODESEPARATORの使用:
    ↑のUpdate Txのコントラクトを見るとコントラクト内に各Update Txのステート番号が含まれているのが分かる(まぁ、これでステート管理しているので当然)。IIDによってRedemy Txのインプットを差し替えたとしても、別のUpdate Txには事なるステート番号が含まれているので、そのままだとRedemy Txの署名は無効なものになってしまう。そのため、Redemy Txでアンロックする際の条件分岐にOP_CODESEPARATORが含まれている。OP_CODESEPARATORは、署名検証時に実行スクリプトを提供する際に、直前のOP_CODESEPARATORより前のスクリプトを除去する。そのため、この条件分岐の署名検証では、スクリプト内のステートは署名にコミットされなくなるので、Redemy Txが古いステートのUpdate Txにインプットを切り替えても、署名は有効なままとなる。ちなみにSettlement Txが使用する条件分岐にはOP_CODESEPARATORは無いので、このような切り替えはできない。
  • ペナルティ:
    仕組みはeltooと似ているが、ブロードキャストされたステートよりも新しいステートが複数ある場合、Redemy Txをブロードキャストするユーザーは自分にとって都合の良い(自分の残高が有利な)Txをブロードキャストすることができる。そういう意味では最新の残高を反映可能なeltooとは少し異なる。ただ、保持するデータ量を最小限に抑えたい場合は、最新のUpdate/Settlement/Redemy Txを保持するだけで良い。

Watchtowerいらず?

ペーパーでは、Watchtowerを排除できるという内容もあった。ただこれは、チャネルのライフタイムを定めるもので、↑のUpdate Txに対して、Funding Txから相対的なタイムロックを設定するというもの。

Update Txのブロードキャストにタイムロックを設定すると、チャネルのライフタイム中は特にチェーンを監視する必要はないし、任意のタイミングでクローズすることもできる。ただ、HTLCのようなマルチホップ決済はどう解決するんだろうか?