Develop with pleasure!

福岡でCloudとかBlockchainとか。

c-lightningのPluginの作り方

c-lightning 0.7から任意の言語でPluginを書けるようになった↓

blockstream.com

現在、CPythonGolangJavaでPluginを書くためのライブラリが提供されている。

c-lightningのPlugin

Pluginを利用することでc-lightningが提供する機能を拡張することができ、そのPluginは任意の言語で書くことができる。これはc-lightningが標準入力と標準出力を介してPluginと連携しているためで、Pluginは標準入力でc-lightningからイベントの通知や、メソッドの要求を待ち受け、Pluginはその応答を標準出力を介して送信する。この標準入力と標準出力で連携する際のデータフォーマットはJSON-RPCv2。

Pluginを作ることで以下のようなことが可能になる。

  • コマンドラインオプションパススルー
    lightningdを介して公開される独自のコマンドラインオプションを登録できる。
  • JSON-RPCコマンドパススルー
    独自のコマンドをJSON-RPCインターフェースに追加することができる。
  • イベント通知
    lightningdからイベントのプッシュ通知を受け取れる。
  • フック
    lightningdの内部イベントの通知を受け取り、その動作を変更したり、カスタム動作を追加したりすることができる。

Pluginの登録方法

Pluginはlightningd起動時に--plugin=オプションで登録できる。複数のPluginを登録したい場合は、Pluginの数分--plugin=オプションを指定する。

$ lightningd --plugin=<Pluginファイルのパス>

なお、Pluginのファイルは実行可能ファイル(実行権限のあるファイル)である必要がある。実行権限が付いてないと、lightningd起動時にPermission deniedで怒られる。

lightningdJSON-RPCリクエストをプラグインの標準入力に書き込み、標準出力からの返信を読み取る。Pluginを初期化する際は以下の2つのRPCメソッドが必要になるため、Pluginを作る際は必ず以下のgetmanifestinitのRPCメソッドを作る必要がある。

実際、Pluginを指定してlightningdを起動すると、Pluginの標準入力に以下のようなgetmanifestを要求するリクエストが飛んでくる(↓ログ形式で表記してるけど、データの実態はINFO -- : 以降)。

INFO -- :  {
INFO -- :   "jsonrpc": "2.0",
INFO -- :   "id": 1,
INFO -- :   "method": "getmanifest",
INFO -- :   "params": {
INFO -- :   }
INFO -- : }
INFO -- :

Pluginは、改行コードが2つ続いたら、それまでのデータをJSONとしてパースして処理すればいい。なお、標準出力はlightningdへの応答になるので、Plugin内でログを出力したい場合は、標準出力以外(ファイルなど)に出力する必要がある。

getmanifest

全てのPluginに必要なメソッドで起動時にパラメータ無しで呼び出される。getmanifestメソッドは以下のような内容を返す。

{
    "options": [
        {
            "name": "greeting",
            "type": "string",
            "default": "World",
            "description": "What name should I call you?"
        }
    ],
    "rpcmethods": [
        {
            "name": "hello",
            "usage": "[name]",
            "description": "Returns a personalized greeting for {greeting} (set via options)."
        },
        {
            "name": "gettime",
            "usage": "",
            "description": "Returns the current time in {timezone}",
            "long_description": "Returns the current time in the timezone that is given as the only parameter.\nThis description may be quite long and is allowed to span multiple lines."
        }
    ],
    "subscriptions": [
        "connect",
        "disconnect"
    ]
}

optionlightningdが受け入れるコマンドラインオプションのリストに追加される。上記の例では、デフォルト値がWorldで指定された定義内容の--greetingオプションが追加される。現在サポートしているのは文字列オプションのみ。

rpcmethodslightningdの組み込みコマンドのようにlightningdJSON-RPC over Unix-Socketインターフェースを介して公開されるメソッドになる。JSON-RPC呼び出しに与えられた任意の引数は、すべて逐次渡される。name、usage、descriptionフィールドは必須だが、long_descriptionは省略可能。usageでは、[]でオプションのパラメータ名を囲む必要がある。

Pluginはrpcmethodの名称が以前に登録されているのと同じでない限り任意の名称を自由に登録できる。これにはgetinfohelpなどの組み込みメソッドも含まれる。名称が競合する場合lightningdはエラーを出力して終了する。

init

getmanifestのレスポンスを返すと、続いてlightningdが受け入れるコマンドラインオプションのリストと、稼働しているlightningdのホームディレクトリとRPCのソケットファイルの情報を含むJSONオブジェクトが送られて来て、lightningdJSON-RPCコマンドを受け取る準備ができたことをPluginに通知するのにinitメソッドが要求される。

{
  "jsonrpc"=>"2.0", 
  "id"=>3, 
  "method"=>"init", 
  "params"=>{
    "options"=>{}, 
    "configuration"=>{
      "lightning-dir"=>"/home/azuchi/.lightning", 
      "rpc-file"=>"lightning-rpc"
    }
  }
}

Pluginはinit呼び出しに応じなければならないが、応答するかどうかは任意で、応答しても現在はlightningdによって破棄される。

イベント通知

上記のRPCメソッドに加えて、Pluginはlightningdのイベントを購読することができる。↑のgetmanifestのレスポンスのsubscriptionsで、購読するイベントを指定する。指定したイベントがlightningdで発生すると、lightningdJSON-RPCを使ってPluginに通知をプッシュする。この時、上記のRPCと違って、JSONにidパラメータは含まれない(通知であり、レスポンスを受け取る必要がないので)。

ただ、現状のlightningdで通知されるイベントは以下の2つのみっぽい。

  • connect
    ピアへの新しい接続が確立された際に通知されるイベントで、接続先ピアのidaddressが通知される。
{
    "id": "02f6725f9c1c40333b67faea92fd211c183050f28df32cac3f9d69685fe9665432",
    "address": "1.2.3.4"
}
  • disconnect
    ピアへの接続が切断された際に通知するイベントで、切断したピアのidが通知される。
{
    "id": "02f6725f9c1c40333b67faea92fd211c183050f28df32cac3f9d69685fe9665432"
}

もうちょっと、通知されるイベント増えないもんかねー。

Hook

Hookを使うと、c-lightningのコードを変更することなく、Pluginでlightningdの動作をカスタマイズできる。このHookとイベントの通知は似ているが、以下の点が異なる。

  • 通知は非同期で、lightningdは通知を送信するが、Pluginがその通知を処理するのを待つことはない。一方Hookは動機的で、Pluginからの応答があるまでlightningdはイベントの処理を完了できない。
  • 通知を受け取るPluginは何個でも登録できるけど、Hookに登録できるPluginは1つだけ(複数のPluginからHookのコールバックで矛盾する結果が返ってくると処理できないため)。

Hookはlightningdの動作を変更できるので、有効なレスポンスをlightningdに返すよう注意して実装する必要がある。

Hookの種類

現在対応しているHookは以下のとおり。

peer_connected

ピアが接続しハンドシェイクが正常に完了した際に呼び出されるHook。接続したピアと既にチャネルを開いている場合は、以下の情報が返ってくる。

{
  "peer": {
    "id": "03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f",
    "addr": "34.239.230.56:9735",
    "globalfeatures": "",
    "localfeatures": ""
  }
}

Pluginからlightningdへの応答は、resultメンバーとしてdisconnectcontinueを返さなければならない。disconnecterror_messageメンバーがある場合、その情報は切断前にピアに送信される。

db_write

変更がデータベースにコミットされる直前に呼び出されるHook。このHookの利用にあたっては、以下のような厳しめの制約がある。

  1. このHookに登録するPluginは応答としてDB操作を引き起こす可能性があるもの(ロギング以外の)を実行してはならない。
  2. このHookを登録しているPluginや他のHookやコマンドに登録してはならない。それらが混在していると↑のルールを破る可能性があるため。
  3. HookはPluginが初期化される前に呼ばれる。

lightningdから以下のようなデータが通知される。

{
  "writes": [ "PRAGMA foreign_keys = ON" ]
}

応答はtrueで、それ以外の場合、データベースへのコミットはされずlightningdはエラーになる。

invoice_payment

まだ支払いがされていないinvoiceに対する有効な支払いが届いた際に呼び出されるHook。

{
  "payment": {
    "label": "unique-label-for-invoice",
    "preimage": "0000000000000000000000000000000000000000000000000000000000000000",
    "msat": "10000msat"
  }
}

応答は、BOLT 4で定義されているゼロでないfailure_codeを返すか、支払いを受け入れる場合は、空のオブジェクトを返す。

openchannel

リモートピアからチャネルオープンの要求が来て、基本的なチェックをパスした際に呼ばれるHookで、lightningdからは以下の情報が送られてくる。

{
  "openchannel": {
    "id": "03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f",
    "funding_satoshis": "100000000msat",
    "push_msat": "0msat",
    "dust_limit_satoshis": "546000msat",
    "max_htlc_value_in_flight_msat": "18446744073709551615msat",
    "channel_reserve_satoshis": "1000000msat",
    "htlc_minimum_msat": "0msat",
    "feerate_per_kw": 7500,
    "to_self_delay": 5,
    "max_accepted_htlcs": 483,
    "channel_flags": 1
  }
}

他にもフィールドがある場合があり、各フィールドはBOLT 2のopen_channelメッセージに定義されている

応答には、resultメンバーに、rejectもしくはcontinue文字列を含める必要がある。rejecterror_messageメンバーがある場合、その情報は切断前にピアに送信される。

PluginをRubyで簡単に書く

↑のPluginをRubyで簡単に書けるようライブラリc-lightningrbを作ってみた↓

github.com

このライブラリを使うと以下のようにDSLでRPCやイベント通知、Hookのハンドラをlambdaで記述できる。lambdaで記述したロジックは、クラスのインスタンスメソッドとして定義されるので、インスタンスの各フィールド、メソッドにアクセス可能。

#!/usr/bin/env ruby
require 'lightning'

class HelloPlugin < Lightning::Plugin

  # PRCの定義。RPCの場合は引数やその定義をc-lightning側に渡す必要があるので、それらをdescで定義。
  desc '[name]', 'Returns a personalized greeting for {greeting} (set via options).'
  define_rpc :hello, -> (name) do
    log.info "log = #{log}"
    "hello #{name}"
  end

  # イベント通知用のハンドラ
  subscribe :connect, ->(id, address) do
    log.info "received connect notification. id = #{id}, address = #{address}"
  end

  # Hookのハンドラ
  hook :peer_connected, ->(peer) do
    log.info "peer_connected. peer = #{peer}"
    {result: 'continue'}
  end

end

p = HelloPlugin.new
p.run

pluginオプションでlightningdにpluginを指定する。この時ファイルに実行権限を付けておくこと。

$ lightningd --network=testnet --plugin=<Pluginファイルのパス>

help実行すると、追加したRPCメソッドの情報もでてくる。

$ lightning-cli help
...
hello [name]
    Returns a personalized greeting for {greeting} (set via options)
...

実行すると、ちゃんとPluginが実行されてるのが分かる。

$  lightning-cli hello lightning                                                                               
"hello lightning"