From 33b7e1dd6d53e26dd8bf1d9c08cd69bf085d4bd4 Mon Sep 17 00:00:00 2001 From: Aljaz Ceru Date: Fri, 18 Aug 2023 17:02:53 +0200 Subject: [PATCH] automation --- .github/actions/build-lspd/action.yaml | 23 + .../actions/process-test-state/action.yaml | 36 + .github/actions/setup-bitcoin/action.yaml | 42 + .github/actions/setup-clightning/action.yaml | 109 ++ .github/actions/setup-itest/action.yaml | 16 + .github/actions/setup-lnd-client/action.yaml | 45 + .github/actions/setup-lnd-lsp/action.yaml | 45 + .github/actions/test-lspd/action.yaml | 144 ++ .github/workflows/integration_tests.yaml | 126 ++ .gitignore | 4 + LICENSE | 20 + README.md | 72 + basetypes/outpoint.go | 19 + basetypes/short_channel_id.go | 51 + basetypes/time.go | 3 + btceclegacy/ciphering.go | 211 +++ chain/fee_estimator.go | 21 + channel_opener_server.go | 437 ++++++ cln/cln_client.go | 282 ++++ cln/cln_interceptor.go | 309 ++++ cln_plugin/channel_acceptor.go | 44 + cln_plugin/cln_messages.go | 117 ++ cln_plugin/cln_plugin.go | 622 +++++++++ cln_plugin/cmd/main.go | 21 + cln_plugin/genproto.sh | 5 + cln_plugin/proto/cln_plugin.pb.go | 787 +++++++++++ cln_plugin/proto/cln_plugin.proto | 56 + cln_plugin/proto/cln_plugin_grpc.pb.go | 137 ++ cln_plugin/server.go | 401 ++++++ config/config.go | 98 ++ deploy/deploy.yml | 333 +++++ deploy/lspd-install.sh | 257 ++++ docs/CLN.md | 74 + docs/LND.md | 53 + docs/aws.md | 42 + docs/bash.md | 57 + go.mod | 194 +++ grpc_server.go | 201 +++ interceptor/email.go | 175 +++ interceptor/htlc_interceptor.go | 7 + interceptor/intercept.go | 427 ++++++ interceptor/store.go | 28 + itest/bob_offline_test.go | 69 + itest/breez_client.go | 122 ++ itest/cln_breez_client.go | 335 +++++ itest/cln_lspd_node.go | 228 +++ itest/cltv_test.go | 58 + itest/config_test.go | 42 + itest/dynamic_fee_test.go | 141 ++ itest/intercept_zero_conf_test.go | 147 ++ itest/lnd_breez_client.go | 116 ++ itest/lnd_lspd_node.go | 251 ++++ itest/lspd_node.go | 366 +++++ itest/lspd_test.go | 166 +++ itest/mempool_api.go | 85 ++ itest/no_balance_test.go | 56 + itest/notification_service.go | 59 + itest/notification_test.go | 309 ++++ itest/postgres.go | 268 ++++ itest/probing_test.go | 75 + itest/regular_forward_test.go | 54 + itest/tag_test.go | 55 + itest/test_common.go | 54 + itest/test_params.go | 46 + itest/zero_conf_utxo_test.go | 77 + itest/zero_reserve_test.go | 64 + lightning/client.go | 41 + lnd/client.go | 528 +++++++ lnd/forwarding_event_store.go | 12 + lnd/forwarding_history.go | 183 +++ lnd/interceptor.go | 259 ++++ lnd/macaroon_credential.go | 25 + main.go | 185 +++ mempool/mempool_client.go | 90 ++ notifications/genproto.sh | 4 + notifications/notification_service.go | 73 + notifications/notifications.pb.go | 218 +++ notifications/notifications.proto | 18 + notifications/notifications_grpc.pb.go | 105 ++ notifications/server.go | 58 + notifications/store.go | 10 + postgresql/connect.go | 17 + postgresql/forwarding_event_store.go | 68 + postgresql/intercept_store.go | 162 +++ postgresql/migrations/000000_.down.sql | 0 postgresql/migrations/000000_.up.sql | 0 .../000001_create_payments_table.down.sql | 1 + .../000001_create_payments_table.up.sql | 5 + postgresql/migrations/000002_details.down.sql | 5 + postgresql/migrations/000002_details.up.sql | 5 + .../migrations/000003_funding_tx.down.sql | 2 + .../migrations/000003_funding_tx.up.sql | 2 + ..._create_probe_payments_hash_index.down.sql | 1 + ...04_create_probe_payments_hash_index.up.sql | 1 + .../000005_forwarding_history.down.sql | 1 + .../000005_forwarding_history.up.sql | 10 + .../migrations/000006_channels.down.sql | 1 + postgresql/migrations/000006_channels.up.sql | 7 + .../000007_channels_last_update.down.sql | 1 + .../000007_channels_last_update.up.sql | 1 + .../000008_one_record_per_channel.down.sql | 21 + .../000008_one_record_per_channel.up.sql | 22 + .../000009_register_payment_tag.down.sql | 1 + .../000009_register_payment_tag.up.sql | 1 + .../000010_opening_fee_params.down.sql | 1 + .../000010_opening_fee_params.up.sql | 1 + .../000011_new_channel_params.down.sql | 1 + .../000011_new_channel_params.up.sql | 11 + .../000012_new_channel_params_token.down.sql | 3 + .../000012_new_channel_params_token.up.sql | 3 + .../000013_notification_subscription.down.sql | 3 + .../000013_notification_subscription.up.sql | 10 + postgresql/notifications_store.go | 76 + rpc/genproto.sh | 4 + rpc/lspd.md | 186 +++ rpc/lspd.pb.go | 1237 +++++++++++++++++ rpc/lspd.proto | 118 ++ rpc/lspd_grpc.pb.go | 213 +++ sample.env | 59 + 119 files changed, 13464 insertions(+) create mode 100644 .github/actions/build-lspd/action.yaml create mode 100644 .github/actions/process-test-state/action.yaml create mode 100644 .github/actions/setup-bitcoin/action.yaml create mode 100644 .github/actions/setup-clightning/action.yaml create mode 100644 .github/actions/setup-itest/action.yaml create mode 100644 .github/actions/setup-lnd-client/action.yaml create mode 100644 .github/actions/setup-lnd-lsp/action.yaml create mode 100644 .github/actions/test-lspd/action.yaml create mode 100644 .github/workflows/integration_tests.yaml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 basetypes/outpoint.go create mode 100644 basetypes/short_channel_id.go create mode 100644 basetypes/time.go create mode 100644 btceclegacy/ciphering.go create mode 100644 chain/fee_estimator.go create mode 100644 channel_opener_server.go create mode 100644 cln/cln_client.go create mode 100644 cln/cln_interceptor.go create mode 100644 cln_plugin/channel_acceptor.go create mode 100644 cln_plugin/cln_messages.go create mode 100644 cln_plugin/cln_plugin.go create mode 100644 cln_plugin/cmd/main.go create mode 100755 cln_plugin/genproto.sh create mode 100644 cln_plugin/proto/cln_plugin.pb.go create mode 100644 cln_plugin/proto/cln_plugin.proto create mode 100644 cln_plugin/proto/cln_plugin_grpc.pb.go create mode 100644 cln_plugin/server.go create mode 100644 config/config.go create mode 100644 deploy/deploy.yml create mode 100644 deploy/lspd-install.sh create mode 100644 docs/CLN.md create mode 100644 docs/LND.md create mode 100644 docs/aws.md create mode 100644 docs/bash.md create mode 100644 go.mod create mode 100644 grpc_server.go create mode 100644 interceptor/email.go create mode 100644 interceptor/htlc_interceptor.go create mode 100644 interceptor/intercept.go create mode 100644 interceptor/store.go create mode 100644 itest/bob_offline_test.go create mode 100644 itest/breez_client.go create mode 100644 itest/cln_breez_client.go create mode 100644 itest/cln_lspd_node.go create mode 100644 itest/cltv_test.go create mode 100644 itest/config_test.go create mode 100644 itest/dynamic_fee_test.go create mode 100644 itest/intercept_zero_conf_test.go create mode 100644 itest/lnd_breez_client.go create mode 100644 itest/lnd_lspd_node.go create mode 100644 itest/lspd_node.go create mode 100644 itest/lspd_test.go create mode 100644 itest/mempool_api.go create mode 100644 itest/no_balance_test.go create mode 100644 itest/notification_service.go create mode 100644 itest/notification_test.go create mode 100644 itest/postgres.go create mode 100644 itest/probing_test.go create mode 100644 itest/regular_forward_test.go create mode 100644 itest/tag_test.go create mode 100644 itest/test_common.go create mode 100644 itest/test_params.go create mode 100644 itest/zero_conf_utxo_test.go create mode 100644 itest/zero_reserve_test.go create mode 100644 lightning/client.go create mode 100644 lnd/client.go create mode 100644 lnd/forwarding_event_store.go create mode 100644 lnd/forwarding_history.go create mode 100644 lnd/interceptor.go create mode 100644 lnd/macaroon_credential.go create mode 100644 main.go create mode 100644 mempool/mempool_client.go create mode 100755 notifications/genproto.sh create mode 100644 notifications/notification_service.go create mode 100644 notifications/notifications.pb.go create mode 100644 notifications/notifications.proto create mode 100644 notifications/notifications_grpc.pb.go create mode 100644 notifications/server.go create mode 100644 notifications/store.go create mode 100644 postgresql/connect.go create mode 100644 postgresql/forwarding_event_store.go create mode 100644 postgresql/intercept_store.go create mode 100644 postgresql/migrations/000000_.down.sql create mode 100644 postgresql/migrations/000000_.up.sql create mode 100644 postgresql/migrations/000001_create_payments_table.down.sql create mode 100644 postgresql/migrations/000001_create_payments_table.up.sql create mode 100644 postgresql/migrations/000002_details.down.sql create mode 100644 postgresql/migrations/000002_details.up.sql create mode 100644 postgresql/migrations/000003_funding_tx.down.sql create mode 100644 postgresql/migrations/000003_funding_tx.up.sql create mode 100644 postgresql/migrations/000004_create_probe_payments_hash_index.down.sql create mode 100644 postgresql/migrations/000004_create_probe_payments_hash_index.up.sql create mode 100644 postgresql/migrations/000005_forwarding_history.down.sql create mode 100644 postgresql/migrations/000005_forwarding_history.up.sql create mode 100644 postgresql/migrations/000006_channels.down.sql create mode 100644 postgresql/migrations/000006_channels.up.sql create mode 100644 postgresql/migrations/000007_channels_last_update.down.sql create mode 100644 postgresql/migrations/000007_channels_last_update.up.sql create mode 100644 postgresql/migrations/000008_one_record_per_channel.down.sql create mode 100644 postgresql/migrations/000008_one_record_per_channel.up.sql create mode 100644 postgresql/migrations/000009_register_payment_tag.down.sql create mode 100644 postgresql/migrations/000009_register_payment_tag.up.sql create mode 100644 postgresql/migrations/000010_opening_fee_params.down.sql create mode 100644 postgresql/migrations/000010_opening_fee_params.up.sql create mode 100644 postgresql/migrations/000011_new_channel_params.down.sql create mode 100644 postgresql/migrations/000011_new_channel_params.up.sql create mode 100644 postgresql/migrations/000012_new_channel_params_token.down.sql create mode 100644 postgresql/migrations/000012_new_channel_params_token.up.sql create mode 100644 postgresql/migrations/000013_notification_subscription.down.sql create mode 100644 postgresql/migrations/000013_notification_subscription.up.sql create mode 100644 postgresql/notifications_store.go create mode 100755 rpc/genproto.sh create mode 100644 rpc/lspd.md create mode 100644 rpc/lspd.pb.go create mode 100644 rpc/lspd.proto create mode 100644 rpc/lspd_grpc.pb.go create mode 100644 sample.env diff --git a/.github/actions/build-lspd/action.yaml b/.github/actions/build-lspd/action.yaml new file mode 100644 index 0000000..4e8d9c1 --- /dev/null +++ b/.github/actions/build-lspd/action.yaml @@ -0,0 +1,23 @@ +name: 'Build LSPD' +description: 'Build LSPD and upload the build artifacts.' +runs: + using: 'composite' + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Build LSPD + run: | + go get github.com/breez/lspd + go get github.com/breez/lspd/cln_plugin + go build . + go build -o lspd_plugin ./cln_plugin/cmd + shell: bash + + - name: Upload build artifacts + uses: actions/upload-artifact@v3 + with: + name: build-artifacts + path: | + ./lspd + ./lspd_plugin diff --git a/.github/actions/process-test-state/action.yaml b/.github/actions/process-test-state/action.yaml new file mode 100644 index 0000000..66cd1f9 --- /dev/null +++ b/.github/actions/process-test-state/action.yaml @@ -0,0 +1,36 @@ +name: "Process Test State" +description: "Check, tar and upload test state" +inputs: + artifact-name: + description: "Name of the artifact" + required: true + default: "test_state_artifact" + test-state-path: + description: "Path of the test state directory" + required: true + default: "/home/runner/test_state" +runs: + using: "composite" + steps: + - name: Check if test_state directory exists + id: check-test-state + run: | + if [ -d "${{ inputs.test-state-path }}" ]; then + echo "exists=true" >> $GITHUB_ENV + else + echo "exists=false" >> $GITHUB_ENV + fi + shell: bash + + - name: Tar state + run: | + find ${{ inputs.test-state-path }} -type f -o -type d | tar -czf ${{ inputs.test-state-path }}.tar.gz -T - + shell: bash + if: env.exists == 'true' + + - name: Upload test_state as artifact + uses: actions/upload-artifact@v3 + with: + name: ${{ inputs.artifact-name }} + path: ${{ inputs.test-state-path }}.tar.gz + if: env.exists == 'true' diff --git a/.github/actions/setup-bitcoin/action.yaml b/.github/actions/setup-bitcoin/action.yaml new file mode 100644 index 0000000..34ad29f --- /dev/null +++ b/.github/actions/setup-bitcoin/action.yaml @@ -0,0 +1,42 @@ +name: 'Setup Bitcoin Core' +description: 'Download and install Bitcoin Core' +inputs: + bitcoin-version: + description: 'Version of Bitcoin Core' + required: true +runs: + using: 'composite' + steps: + - name: Cache Bitcoin Core + id: cache-bitcoin + uses: actions/cache@v3 + with: + path: | + ~/bitcoin-core-${{ inputs.bitcoin-version }}/bitcoin-${{ inputs.bitcoin-version }}/bin/bitcoind + ~/bitcoin-core-${{ inputs.bitcoin-version }}/bitcoin-${{ inputs.bitcoin-version }}/bin/bitcoin-cli + key: bitcoin-core-${{ inputs.bitcoin-version }} + + - name: Setup dependencies + if: steps.cache-bitcoin.outputs.cache-hit != 'true' + run: | + sudo apt-get update + sudo apt-get install -y axel + shell: bash + + - name: Download and install Bitcoin Core + if: steps.cache-bitcoin.outputs.cache-hit != 'true' + run: | + mkdir -p ~/bitcoin-core-${{ inputs.bitcoin-version }} + cd ~/bitcoin-core-${{ inputs.bitcoin-version }} + axel https://bitcoincore.org/bin/bitcoin-core-${{ inputs.bitcoin-version }}/bitcoin-${{ inputs.bitcoin-version }}-x86_64-linux-gnu.tar.gz + tar -xzf bitcoin-${{ inputs.bitcoin-version }}-x86_64-linux-gnu.tar.gz + rm bitcoin-${{ inputs.bitcoin-version }}-x86_64-linux-gnu.tar.gz + sudo cp ~/bitcoin-core-${{ inputs.bitcoin-version }}/bitcoin-${{ inputs.bitcoin-version }}/bin/bitcoind /usr/bin/ + sudo cp ~/bitcoin-core-${{ inputs.bitcoin-version }}/bitcoin-${{ inputs.bitcoin-version }}/bin/bitcoin-cli /usr/bin/ + shell: bash + + - name: Copy Binaries + run: | + sudo cp ~/bitcoin-core-${{ inputs.bitcoin-version }}/bitcoin-${{ inputs.bitcoin-version }}/bin/bitcoind /usr/bin/ + sudo cp ~/bitcoin-core-${{ inputs.bitcoin-version }}/bitcoin-${{ inputs.bitcoin-version }}/bin/bitcoin-cli /usr/bin/ + shell: bash diff --git a/.github/actions/setup-clightning/action.yaml b/.github/actions/setup-clightning/action.yaml new file mode 100644 index 0000000..fe4f5f1 --- /dev/null +++ b/.github/actions/setup-clightning/action.yaml @@ -0,0 +1,109 @@ +name: 'Setup Core Lightning' +description: 'Set up Core Lightning on the runner' + +inputs: + checkout-version: + description: 'v23.05.1' + required: true + default: 'v23.05.1' + +runs: + using: 'composite' + steps: + - name: Cache Core Lightning + id: cache-core-lightning + uses: actions/cache@v3 + with: + path: | + lightning_git/lightningd/lightning_hsmd + lightning_git/lightningd/lightning_gossipd + lightning_git/lightningd/lightning_openingd + lightning_git/lightningd/lightning_dualopend + lightning_git/lightningd/lightning_channeld + lightning_git/lightningd/lightning_closingd + lightning_git/lightningd/lightning_onchaind + lightning_git/lightningd/lightning_connectd + lightning_git/lightningd/lightning_websocketd + lightning_git/lightningd/lightningd + lightning_git/plugins/offers + lightning_git/plugins/topology + lightning_git/plugins/spenderp + lightning_git/plugins/test/run-route-overlong + lightning_git/plugins/test/run-funder_policy + lightning_git/plugins/pay + lightning_git/plugins/bkpr/test/run-bkpr_db + lightning_git/plugins/bkpr/test/run-recorder + lightning_git/plugins/funder + lightning_git/plugins/bookkeeper + lightning_git/plugins/txprepare + lightning_git/plugins/keysend + lightning_git/plugins/fetchinvoice + lightning_git/plugins/bcli + lightning_git/plugins/cln-grpc + lightning_git/plugins/commando + lightning_git/plugins/autoclean + lightning_git/plugins/chanbackup + lightning_git/plugins/sql + lightning_git/cli/lightning-cli + lightning_git/devtools/bolt11-cli + lightning_git/devtools/decodemsg + lightning_git/devtools/onion + lightning_git/devtools/dump-gossipstore + lightning_git/devtools/gossipwith + lightning_git/devtools/create-gossipstore + lightning_git/devtools/mkcommit + lightning_git/devtools/mkfunding + lightning_git/devtools/mkclose + lightning_git/devtools/mkgossip + key: core-lightning-${{ inputs.checkout-version }} + + - name: Setup Python 3.8 + if: steps.cache-core-lightning.outputs.cache-hit != 'true' + uses: actions/setup-python@v4 + with: + python-version: 3.8 + + - name: Setup Rust + if: steps.cache-core-lightning.outputs.cache-hit != 'true' + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + profile: minimal + override: true + + - name: Install dependencies + if: steps.cache-core-lightning.outputs.cache-hit != 'true' + run: | + sudo apt-get install -y autoconf automake build-essential git libtool libgmp-dev libsqlite3-dev python3 python3-pip net-tools zlib1g-dev libsodium-dev gettext valgrind libpq-dev shellcheck cppcheck libsecp256k1-dev jq + sudo apt-get remove -y protobuf-compiler + curl -OL https://github.com/protocolbuffers/protobuf/releases/download/v3.12.0/protoc-3.12.0-linux-x86_64.zip + sudo unzip -o protoc-3.12.0-linux-x86_64.zip -d /usr/local bin/protoc + sudo unzip -o protoc-3.12.0-linux-x86_64.zip -d /usr/local 'include/*' + rm -f protoc-3.12.0-linux-x86_64.zip + sudo chmod 755 /usr/local/bin/protoc + shell: bash + + - name: Install Python dependencies + if: steps.cache-core-lightning.outputs.cache-hit != 'true' + run: | + pip3 install --upgrade pip + pip3 install poetry mako + pip install grpcio-tools + shell: bash + + - name: Checkout and build lightning + if: steps.cache-core-lightning.outputs.cache-hit != 'true' + uses: actions/checkout@v3 + with: + repository: ElementsProject/lightning + ref: ${{ inputs.checkout-version }} + path: lightning_git + + - name: Build Lightning + if: steps.cache-core-lightning.outputs.cache-hit != 'true' + run: | + cd lightning_git + ./configure --enable-developer --enable-rust + poetry install + poetry run make -j `nproc` + shell: bash diff --git a/.github/actions/setup-itest/action.yaml b/.github/actions/setup-itest/action.yaml new file mode 100644 index 0000000..43c8504 --- /dev/null +++ b/.github/actions/setup-itest/action.yaml @@ -0,0 +1,16 @@ +name: 'Cache itest' +description: 'Fetch LSPD Integration Test and cache the go directory' +runs: + using: 'composite' + steps: + - name: Cache itest + id: cache-itest + uses: actions/cache@v3 + with: + path: | + ~/go + key: itest + - name: Get LSPD Integration Test + if: steps.cache-itest.outputs.cache-hit != 'true' + run: go get github.com/breez/lspd/itest + shell: bash \ No newline at end of file diff --git a/.github/actions/setup-lnd-client/action.yaml b/.github/actions/setup-lnd-client/action.yaml new file mode 100644 index 0000000..accbbb6 --- /dev/null +++ b/.github/actions/setup-lnd-client/action.yaml @@ -0,0 +1,45 @@ +name: 'Setup LND Client' +description: 'Set up LND for the Client on the runner' + +inputs: + client-ref: + description: 'The Git reference for the Client version of LND' + required: true + default: 'v0.16.2-breez' + + go-version: + description: 'The Go version for building LND' + required: true + default: ^1.19 + +runs: + using: 'composite' + steps: + - name: Cache LND client + id: cache-lnd-client + uses: actions/cache@v3 + with: + path: | + ~/go_lnd_client/bin/lnd + key: go_lnd_client-${{ inputs.client-ref }}-${{ inputs.go-version }} + + - name: Set up Go 1.x + if: steps.cache-lnd-client.outputs.cache-hit != 'true' + uses: actions/setup-go@v4 + with: + go-version: ${{ inputs.go-version }} + + - name: Checkout LND for Client + if: steps.cache-lnd-client.outputs.cache-hit != 'true' + uses: actions/checkout@v3 + with: + repository: breez/lnd + ref: ${{ inputs.client-ref }} + path: lnd_client + + - name: Build LND for Client + if: steps.cache-lnd-client.outputs.cache-hit != 'true' + run: | + cd lnd_client + env GOPATH=~/go_lnd_client make install + shell: bash diff --git a/.github/actions/setup-lnd-lsp/action.yaml b/.github/actions/setup-lnd-lsp/action.yaml new file mode 100644 index 0000000..8074010 --- /dev/null +++ b/.github/actions/setup-lnd-lsp/action.yaml @@ -0,0 +1,45 @@ +name: 'Setup LND LSP' +description: 'Set up LND for LSP on the runner' + +inputs: + lsp-ref: + description: 'The Git reference for the LSP version of LND' + required: true + default: 'breez-node-v0.16.2-beta' + + go-version: + description: 'The Go version for building LND' + required: true + default: ^1.19 + +runs: + using: 'composite' + steps: + - name: Cache LND LSP + id: cache-lnd-lsp + uses: actions/cache@v3 + with: + path: | + ~/go_lnd_lsp/bin/lnd + key: go_lnd_lsp-${{ inputs.lsp-ref }}-${{ inputs.go-version }} + + - name: Set up Go 1.x + if: steps.cache-lnd-lsp.outputs.cache-hit != 'true' + uses: actions/setup-go@v4 + with: + go-version: ${{ inputs.go-version }} + + - name: Checkout LND for LSP + if: steps.cache-lnd-lsp.outputs.cache-hit != 'true' + uses: actions/checkout@v3 + with: + repository: breez/lnd + ref: ${{ inputs.lsp-ref }} + path: lnd_lsp + + - name: Build LND for LSP + if: steps.cache-lnd-lsp.outputs.cache-hit != 'true' + run: | + cd lnd_lsp + env GOPATH=~/go_lnd_lsp make install tags='submarineswaprpc chanreservedynamic routerrpc walletrpc chainrpc signrpc invoicesrpc' + shell: bash diff --git a/.github/actions/test-lspd/action.yaml b/.github/actions/test-lspd/action.yaml new file mode 100644 index 0000000..bd09fe8 --- /dev/null +++ b/.github/actions/test-lspd/action.yaml @@ -0,0 +1,144 @@ +name: 'Test LSPD' +description: 'Downloads artifacts, sets permissions, caches and runs a specified test and processes the result as an artifact.' +inputs: + TESTRE: + description: 'Test regular expression to run.' + required: true + artifact-name: + description: 'Artifact name for the test state.' + required: true + bitcoin-version: + description: 'Bitcoin version.' + required: true + LSP_REF: + description: 'LSP reference.' + required: true + CLIENT_REF: + description: 'Client reference.' + required: true + GO_VERSION: + description: 'Go version.' + required: true + CLN_VERSION: + description: 'Core Lightning version.' + required: true +runs: + using: 'composite' + steps: + - name: Download build artifacts + uses: actions/download-artifact@v3 + with: + name: build-artifacts + + - name: Set permissions + run: | + chmod 755 lspd + chmod 755 lspd_plugin + shell: bash + + - name: Cache LND client + uses: actions/cache@v3 + with: + path: ~/go_lnd_client/bin/lnd + key: go_lnd_client-${{ inputs.CLIENT_REF }}-${{ inputs.GO_VERSION }} + + - name: Cache LND LSP + uses: actions/cache@v3 + with: + path: ~/go_lnd_lsp/bin/lnd + key: go_lnd_lsp-${{ inputs.LSP_REF }}-${{ inputs.GO_VERSION }} + + - name: Cache Core Lightning + uses: actions/cache@v3 + with: + path: | + lightning_git/lightningd/lightning_hsmd + lightning_git/lightningd/lightning_gossipd + lightning_git/lightningd/lightning_openingd + lightning_git/lightningd/lightning_dualopend + lightning_git/lightningd/lightning_channeld + lightning_git/lightningd/lightning_closingd + lightning_git/lightningd/lightning_onchaind + lightning_git/lightningd/lightning_connectd + lightning_git/lightningd/lightning_websocketd + lightning_git/lightningd/lightningd + lightning_git/plugins/offers + lightning_git/plugins/topology + lightning_git/plugins/spenderp + lightning_git/plugins/test/run-route-overlong + lightning_git/plugins/test/run-funder_policy + lightning_git/plugins/pay + lightning_git/plugins/bkpr/test/run-bkpr_db + lightning_git/plugins/bkpr/test/run-recorder + lightning_git/plugins/funder + lightning_git/plugins/bookkeeper + lightning_git/plugins/txprepare + lightning_git/plugins/keysend + lightning_git/plugins/fetchinvoice + lightning_git/plugins/bcli + lightning_git/plugins/cln-grpc + lightning_git/plugins/commando + lightning_git/plugins/autoclean + lightning_git/plugins/chanbackup + lightning_git/plugins/sql + lightning_git/cli/lightning-cli + lightning_git/devtools/bolt11-cli + lightning_git/devtools/decodemsg + lightning_git/devtools/onion + lightning_git/devtools/dump-gossipstore + lightning_git/devtools/gossipwith + lightning_git/devtools/create-gossipstore + lightning_git/devtools/mkcommit + lightning_git/devtools/mkfunding + lightning_git/devtools/mkclose + lightning_git/devtools/mkgossip + key: core-lightning-${{ inputs.CLN_VERSION }} + + - name: Cache Bitcoin Core + uses: actions/cache@v3 + with: + path: | + ~/bitcoin-core-${{ inputs.bitcoin-version }}/bitcoin-${{ inputs.bitcoin-version }}/bin/bitcoind + ~/bitcoin-core-${{ inputs.bitcoin-version }}/bitcoin-${{ inputs.bitcoin-version }}/bin/bitcoin-cli + key: bitcoin-core-${{ inputs.bitcoin-version }} + + - name: Cache itest + id: cache-itest + uses: actions/cache@v3 + with: + path: | + ~/go + key: itest + + - name: Test LSPD + run: | + go get github.com/breez/lspd/itest + go test -timeout 45m -v \ + ./itest \ + -test.run \ + ${{ inputs.TESTRE }} \ + --bitcoindexec ~/bitcoin-core-${{ inputs.bitcoin-version }}/bitcoin-${{ inputs.bitcoin-version }}/bin/bitcoind \ + --bitcoincliexec ~/bitcoin-core-${{ inputs.bitcoin-version }}/bitcoin-${{ inputs.bitcoin-version }}/bin/bitcoin-cli \ + --lightningdexec ${{ github.workspace }}/lightning_git/lightningd/lightningd \ + --lndexec ~/go_lnd_lsp/bin/lnd \ + --lndmobileexec ~/go_lnd_client/bin/lnd \ + --clnpluginexec ${{ github.workspace }}/lspd_plugin \ + --lspdexec ${{ github.workspace }}/lspd \ + --lspdmigrationsdir ${{ github.workspace }}/postgresql/migrations \ + --preservelogs \ + --testdir /home/runner/test_state || echo "step_failed=true" >> $GITHUB_ENV + shell: bash + + - name: Process test state + uses: ./.github/actions/process-test-state + with: + artifact-name: ${{ inputs.artifact-name }} + test-state-path: /home/runner/test_state + + - name: Fail the workflow if the tests failed + run: | + if [[ "${{ env.step_failed }}" == "true" ]] + then + exit 1 + fi + shell: bash diff --git a/.github/workflows/integration_tests.yaml b/.github/workflows/integration_tests.yaml new file mode 100644 index 0000000..66677eb --- /dev/null +++ b/.github/workflows/integration_tests.yaml @@ -0,0 +1,126 @@ +name: integration tests +on: + push: + branches: [ master ] + pull_request: +env: + BITCOIN_VERSION: '25.0' + LSP_REF: 'breez-node-v0.16.4-beta' + CLIENT_REF: 'v0.16.4-breez-2' + GO_VERSION: '^1.19' + CLN_VERSION: 'v23.05.1' +jobs: + + setup-bitcoin-core: + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up Bitcoin Core + if: steps.cache-bitcoin.outputs.cache-hit != 'true' + uses: ./.github/actions/setup-bitcoin + with: + bitcoin-version: ${{ env.BITCOIN_VERSION }} + + setup-lnd-lsp: + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up LND LSP + if: steps.cache-lnd-lsp.outputs.cache-hit != 'true' + uses: ./.github/actions/setup-lnd-lsp + with: + lsp-ref: ${{ env.LSP_REF }} + go-version: ${{ env.GO_VERSION }} + + setup-lnd-client: + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up LND client + if: steps.cache-lnd-client.outputs.cache-hit != 'true' + uses: ./.github/actions/setup-lnd-client + with: + client-ref: ${{ env.CLIENT_REF }} + go-version: ${{ env.GO_VERSION }} + + setup-cln: + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up Core Lightning + uses: ./.github/actions/setup-clightning + with: + checkout-version: ${{ env.CLN_VERSION }} + + build-lspd: + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Build LSPD and Upload Artifacts + uses: ./.github/actions/build-lspd + + setup-itest: + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Setup itest + uses: ./.github/actions/setup-itest + + + run-test: + runs-on: ubuntu-22.04 + needs: + - setup-itest + - setup-bitcoin-core + - setup-lnd-client + - setup-lnd-lsp + - setup-cln + - build-lspd + name: test ${{ matrix.implementation }} ${{ matrix.test }} + strategy: + max-parallel: 6 + matrix: + test: [ + testOpenZeroConfChannelOnReceive, + testOpenZeroConfSingleHtlc, + testZeroReserve, + testFailureBobOffline, + testNoBalance, + testRegularForward, + testProbing, + testInvalidCltv, + registerPaymentWithTag, + testOpenZeroConfUtxo, + testDynamicFeeFlow, + testOfflineNotificationPaymentRegistered, + testOfflineNotificationRegularForward, + testOfflineNotificationZeroConfChannel, + ] + implementation: [ + LND, + CLN + ] + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Run and Process Test State + uses: ./.github/actions/test-lspd + with: + TESTRE: "TestLspd/${{ matrix.implementation }}-lspd:_${{ matrix.test }}" + artifact-name: TestLspd-${{ matrix.implementation }}-lspd_${{ matrix.test }} + bitcoin-version: ${{ env.BITCOIN_VERSION }} + LSP_REF: ${{ env.LSP_REF }} + CLIENT_REF: ${{ env.CLIENT_REF }} + GO_VERSION: ${{ env.GO_VERSION }} + CLN_VERSION: ${{ env.CLN_VERSION }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c9e8d7c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.vscode +go.sum +lspd +lspd_plugin diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6560904 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2022 Breez + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..818b3b6 --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# lspd simple server +lspd is a simple deamon that provides [LSP](https://medium.com/breez-technology/introducing-lightning-service-providers-fe9fb1665d5f) services to [Breez clients](https://github.com/breez/breezmobile). + +This is a simple example of an lspd that works with an [lnd](https://github.com/lightningnetwork/lnd) node or a [cln](https://github.com/ElementsProject/lightning) node. + +## Deployment +Installation and configuration instructions for both implementations can be found here: +### Manual install +- [CLN](./docs/CLN.md) - step by step installation instructions for CLN +- [LND](./docs/LND.md) - step by step installation instructions for LND +### Automated deployment +- [AWS](./docs/aws.md) - automated deployment of bitcoind, CLN and lspd to AWS, together with +- [Bash](./docs/bash.md) - install everything on any debian/ubuntu server + +## Implement your own lspd +You can create your own lsdp by implementing the grpc methods described [here](https://github.com/breez/lspd/blob/master/rpc/lspd.md). + +## Flow for creating channels +When Alice wants Bob to pay her an amount and Alice doesn't have a channel with sufficient capacity, she calls the lspd function RegisterPayment() and sending the paymentHash, paymentSecret (for mpp payments), destination (Alice pubkey), and two amounts. +The first amount (incoming from the lsp point of view) is the amount BOB will pay. The second amount (outgoing from the lsp point of view) is the amount Alice will receive. The difference between these two amounts is the fees for the lsp. +In order to open the channel on the fly, the lsp is connecting to lnd using the interceptor api. + +## Probing support +The lsp supports probing non-mpp payments if the payment hash for probing is sha256('probing-01:' || payment_hash) when payment_hash is the hash of the real payment. + +## Integration tests +In order to run the integration tests, you need: +- Docker running +- python3 installed +- A development build of lightningd v23.05.1 +- lnd v0.16.4 lsp version https://github.com/breez/lnd/commit/cebcdf1b17fdedf7d69207d98c31cf8c3b257531 +- lnd v0.16.4 breez client version https://github.com/breez/lnd/commit/3c0854adcfc924a6d759a6ee4640c41266b9f8b4 +- bitcoind (tested with v23.0) +- bitcoin-cli (tested with v23.0) +- build of lspd (go build .) +- build of lspd cln plugin (go build -o lspd_plugin cln_plugin/cmd) + +To run the integration tests, run the following command from the lspd root directory (replacing the appropriate paths). + +``` +go test -timeout 20m -v ./itest \ + --lightningdexec /full/path/to/lightningd \ + --lndexec /full/path/to/lnd \ + --lndmobileexec /full/path/to/lnd \ + --clnpluginexec /full/path/to/lspd_plugin \ + --lspdexec /full/path/to/lspd \ + --lspdmigrationsdir /full/path/to/lspd/postgresql/migrations +``` + +- Required: `--lightningdexec` Full path to lightningd development build executable. Defaults to `lightningd` in `$PATH`. +- Required: `--lndexec` Full path to LSP LND executable. Defaults to `lnd` in `$PATH`. +- Required: `--lndmobileexec` Full path to Breez mobile client LND executable. No default. +- Required: `--lspdexec` Full path to `lspd` executable to test. Defaults to `lspd` in `$PATH`. +- Required: `--clnpluginexec` Full path to the lspd cln plugin executable. No default. +- Required: `--lspdmigrationsdir` Path to directory containing postgres migrations for lspd. (Should be `./postgresql/migrations`) +- Recommended: `--bitcoindexec` Full path to `bitcoind`. Defaults to `bitcoind` in `$PATH`. +- Recommended: `--bitcoincliexec` Full path to `bitcoin-cli`. Defaults to `bitcoin-cli` in `$PATH`. +- Recommended: `--testdir` uses the testdir as root directory for test files. Recommended because the CLN `lightning-rpc` socket max path length is 104-108 characters. Defaults to a temp directory (which has a long path length usually). +- Optional: `--preservelogs` persists only the logs in the testing directory. +- Optional: `--preservestate` preserves all artifacts from the lightning nodes, miners, postgres container and startup scripts. +- Optional: `--dumplogs` dumps all logs to the console after a test is complete. + +Unfortunately the tests cannot be cancelled with CTRL+C without having to clean +up some artefacts. Here's where to look: +- lspd process +- lightningd process +- lnd process +- bitcoind process +- docker container for postgres with default name + +It may be a good idea to clean your testdir every once in a while if you're +using the `preservelogs` or `preservestate` flags. \ No newline at end of file diff --git a/basetypes/outpoint.go b/basetypes/outpoint.go new file mode 100644 index 0000000..cdfa979 --- /dev/null +++ b/basetypes/outpoint.go @@ -0,0 +1,19 @@ +package basetypes + +import ( + "log" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" +) + +func NewOutPoint(fundingTxID []byte, index uint32) (*wire.OutPoint, error) { + var h chainhash.Hash + err := h.SetBytes(fundingTxID) + if err != nil { + log.Printf("h.SetBytes(%x) error: %v", fundingTxID, err) + return nil, err + } + + return wire.NewOutPoint(&h, index), nil +} diff --git a/basetypes/short_channel_id.go b/basetypes/short_channel_id.go new file mode 100644 index 0000000..4b88c7a --- /dev/null +++ b/basetypes/short_channel_id.go @@ -0,0 +1,51 @@ +package basetypes + +import ( + "fmt" + "strconv" + "strings" + + "github.com/lightningnetwork/lnd/lnwire" +) + +type ShortChannelID uint64 + +func NewShortChannelIDFromString(channelID string) (*ShortChannelID, error) { + if channelID == "" { + c := ShortChannelID(0) + return &c, nil + } + + fields := strings.Split(channelID, "x") + if len(fields) != 3 { + return nil, fmt.Errorf("invalid short channel id %v", channelID) + } + var blockHeight, txIndex, txPos int64 + var err error + if blockHeight, err = strconv.ParseInt(fields[0], 10, 64); err != nil { + return nil, fmt.Errorf("failed to parse block height %v", fields[0]) + } + if txIndex, err = strconv.ParseInt(fields[1], 10, 64); err != nil { + return nil, fmt.Errorf("failed to parse block height %v", fields[1]) + } + if txPos, err = strconv.ParseInt(fields[2], 10, 64); err != nil { + return nil, fmt.Errorf("failed to parse block height %v", fields[2]) + } + + result := ShortChannelID( + lnwire.ShortChannelID{ + BlockHeight: uint32(blockHeight), + TxIndex: uint32(txIndex), + TxPosition: uint16(txPos), + }.ToUint64(), + ) + return &result, nil +} + +func (c *ShortChannelID) ToString() string { + u := uint64(*c) + blockHeight := (u >> 40) & 0xFFFFFF + txIndex := (u >> 16) & 0xFFFFFF + outputIndex := u & 0xFFFF + return fmt.Sprintf("%dx%dx%d", blockHeight, txIndex, outputIndex) +} diff --git a/basetypes/time.go b/basetypes/time.go new file mode 100644 index 0000000..6c28c37 --- /dev/null +++ b/basetypes/time.go @@ -0,0 +1,3 @@ +package basetypes + +var TIME_FORMAT string = "2006-01-02T15:04:05.999Z" diff --git a/btceclegacy/ciphering.go b/btceclegacy/ciphering.go new file mode 100644 index 0000000..ee2260e --- /dev/null +++ b/btceclegacy/ciphering.go @@ -0,0 +1,211 @@ +// Copyright (c) 2015-2016 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package btceclegacy + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/sha512" + "errors" + "io" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/decred/dcrd/dcrec/secp256k1/v4" +) + +var ( + // ErrInvalidMAC occurs when Message Authentication Check (MAC) fails + // during decryption. This happens because of either invalid private key or + // corrupt ciphertext. + ErrInvalidMAC = errors.New("invalid mac hash") + + // errInputTooShort occurs when the input ciphertext to the Decrypt + // function is less than 134 bytes long. + errInputTooShort = errors.New("ciphertext too short") + + // errUnsupportedCurve occurs when the first two bytes of the encrypted + // text aren't 0x02CA (= 712 = secp256k1, from OpenSSL). + errUnsupportedCurve = errors.New("unsupported curve") + + errInvalidXLength = errors.New("invalid X length, must be 32") + errInvalidYLength = errors.New("invalid Y length, must be 32") + errInvalidPadding = errors.New("invalid PKCS#7 padding") + + // 0x02CA = 714 + ciphCurveBytes = [2]byte{0x02, 0xCA} + // 0x20 = 32 + ciphCoordLength = [2]byte{0x00, 0x20} +) + +// Encrypt encrypts data for the target public key using AES-256-CBC. It also +// generates a private key (the pubkey of which is also in the output). The only +// supported curve is secp256k1. The `structure' that it encodes everything into +// is: +// +// struct { +// // Initialization Vector used for AES-256-CBC +// IV [16]byte +// // Public Key: curve(2) + len_of_pubkeyX(2) + pubkeyX + +// // len_of_pubkeyY(2) + pubkeyY (curve = 714) +// PublicKey [70]byte +// // Cipher text +// Data []byte +// // HMAC-SHA-256 Message Authentication Code +// HMAC [32]byte +// } +// +// The primary aim is to ensure byte compatibility with Pyelliptic. Also, refer +// to section 5.8.1 of ANSI X9.63 for rationale on this format. +func Encrypt(pubkey *btcec.PublicKey, in []byte) ([]byte, error) { + ephemeral, err := btcec.NewPrivateKey() + if err != nil { + return nil, err + } + ecdhKey := secp256k1.GenerateSharedSecret(ephemeral, pubkey) + derivedKey := sha512.Sum512(ecdhKey) + keyE := derivedKey[:32] + keyM := derivedKey[32:] + + paddedIn := addPKCSPadding(in) + // IV + Curve params/X/Y + padded plaintext/ciphertext + HMAC-256 + out := make([]byte, aes.BlockSize+70+len(paddedIn)+sha256.Size) + iv := out[:aes.BlockSize] + if _, err = io.ReadFull(rand.Reader, iv); err != nil { + return nil, err + } + // start writing public key + pb := ephemeral.PubKey().SerializeUncompressed() + offset := aes.BlockSize + + // curve and X length + copy(out[offset:offset+4], append(ciphCurveBytes[:], ciphCoordLength[:]...)) + offset += 4 + // X + copy(out[offset:offset+32], pb[1:33]) + offset += 32 + // Y length + copy(out[offset:offset+2], ciphCoordLength[:]) + offset += 2 + // Y + copy(out[offset:offset+32], pb[33:]) + offset += 32 + + // start encryption + block, err := aes.NewCipher(keyE) + if err != nil { + return nil, err + } + mode := cipher.NewCBCEncrypter(block, iv) + mode.CryptBlocks(out[offset:len(out)-sha256.Size], paddedIn) + + // start HMAC-SHA-256 + hm := hmac.New(sha256.New, keyM) + hm.Write(out[:len(out)-sha256.Size]) // everything is hashed + copy(out[len(out)-sha256.Size:], hm.Sum(nil)) // write checksum + + return out, nil +} + +// Decrypt decrypts data that was encrypted using the Encrypt function. +func Decrypt(priv *btcec.PrivateKey, in []byte) ([]byte, error) { + // IV + Curve params/X/Y + 1 block + HMAC-256 + if len(in) < aes.BlockSize+70+aes.BlockSize+sha256.Size { + return nil, errInputTooShort + } + + // read iv + iv := in[:aes.BlockSize] + offset := aes.BlockSize + + // start reading pubkey + if !bytes.Equal(in[offset:offset+2], ciphCurveBytes[:]) { + return nil, errUnsupportedCurve + } + offset += 2 + + if !bytes.Equal(in[offset:offset+2], ciphCoordLength[:]) { + return nil, errInvalidXLength + } + offset += 2 + + xBytes := in[offset : offset+32] + offset += 32 + + if !bytes.Equal(in[offset:offset+2], ciphCoordLength[:]) { + return nil, errInvalidYLength + } + offset += 2 + + yBytes := in[offset : offset+32] + offset += 32 + + pb := make([]byte, 65) + pb[0] = byte(0x04) // uncompressed + copy(pb[1:33], xBytes) + copy(pb[33:], yBytes) + // check if (X, Y) lies on the curve and create a Pubkey if it does + pubkey, err := btcec.ParsePubKey(pb) + if err != nil { + return nil, err + } + + // check for cipher text length + if (len(in)-aes.BlockSize-offset-sha256.Size)%aes.BlockSize != 0 { + return nil, errInvalidPadding // not padded to 16 bytes + } + + // read hmac + messageMAC := in[len(in)-sha256.Size:] + + // generate shared secret + ecdhKey := secp256k1.GenerateSharedSecret(priv, pubkey) + derivedKey := sha512.Sum512(ecdhKey) + keyE := derivedKey[:32] + keyM := derivedKey[32:] + + // verify mac + hm := hmac.New(sha256.New, keyM) + hm.Write(in[:len(in)-sha256.Size]) // everything is hashed + expectedMAC := hm.Sum(nil) + if !hmac.Equal(messageMAC, expectedMAC) { + return nil, ErrInvalidMAC + } + + // start decryption + block, err := aes.NewCipher(keyE) + if err != nil { + return nil, err + } + mode := cipher.NewCBCDecrypter(block, iv) + // same length as ciphertext + plaintext := make([]byte, len(in)-offset-sha256.Size) + mode.CryptBlocks(plaintext, in[offset:len(in)-sha256.Size]) + + return removePKCSPadding(plaintext) +} + +// Implement PKCS#7 padding with block size of 16 (AES block size). + +// addPKCSPadding adds padding to a block of data +func addPKCSPadding(src []byte) []byte { + padding := aes.BlockSize - len(src)%aes.BlockSize + padtext := bytes.Repeat([]byte{byte(padding)}, padding) + return append(src, padtext...) +} + +// removePKCSPadding removes padding from data that was added with addPKCSPadding +func removePKCSPadding(src []byte) ([]byte, error) { + length := len(src) + padLength := int(src[length-1]) + if padLength > aes.BlockSize || length < aes.BlockSize { + return nil, errInvalidPadding + } + + return src[:length-padLength], nil +} diff --git a/chain/fee_estimator.go b/chain/fee_estimator.go new file mode 100644 index 0000000..8560a68 --- /dev/null +++ b/chain/fee_estimator.go @@ -0,0 +1,21 @@ +package chain + +import "context" + +type FeeStrategy int + +const ( + FeeStrategyFastest FeeStrategy = 0 + FeeStrategyHalfHour FeeStrategy = 1 + FeeStrategyHour FeeStrategy = 2 + FeeStrategyEconomy FeeStrategy = 3 + FeeStrategyMinimum FeeStrategy = 4 +) + +type FeeEstimation struct { + SatPerVByte float64 +} + +type FeeEstimator interface { + EstimateFeeRate(context.Context, FeeStrategy) (*FeeEstimation, error) +} diff --git a/channel_opener_server.go b/channel_opener_server.go new file mode 100644 index 0000000..064a874 --- /dev/null +++ b/channel_opener_server.go @@ -0,0 +1,437 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "log" + "sort" + "time" + + "github.com/breez/lspd/basetypes" + "github.com/breez/lspd/btceclegacy" + "github.com/breez/lspd/interceptor" + "github.com/breez/lspd/lightning" + lspdrpc "github.com/breez/lspd/rpc" + ecies "github.com/ecies/go/v2" + "google.golang.org/protobuf/proto" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/ecdsa" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/lnwire" +) + +type channelOpenerServer struct { + lspdrpc.ChannelOpenerServer + store interceptor.InterceptStore +} + +func NewChannelOpenerServer( + store interceptor.InterceptStore, +) *channelOpenerServer { + return &channelOpenerServer{ + store: store, + } +} + +type contextKey string + +func (s *channelOpenerServer) ChannelInformation(ctx context.Context, in *lspdrpc.ChannelInformationRequest) (*lspdrpc.ChannelInformationReply, error) { + node, token, err := s.getNode(ctx) + if err != nil { + return nil, err + } + + params, err := s.createOpeningParamsMenu(ctx, node, token) + if err != nil { + return nil, err + } + + return &lspdrpc.ChannelInformationReply{ + Name: node.nodeConfig.Name, + Pubkey: node.nodeConfig.NodePubkey, + Host: node.nodeConfig.Host, + ChannelCapacity: int64(node.nodeConfig.PublicChannelAmount), + TargetConf: int32(node.nodeConfig.TargetConf), + MinHtlcMsat: int64(node.nodeConfig.MinHtlcMsat), + BaseFeeMsat: int64(node.nodeConfig.BaseFeeMsat), + FeeRate: node.nodeConfig.FeeRate, + TimeLockDelta: node.nodeConfig.TimeLockDelta, + ChannelFeePermyriad: int64(node.nodeConfig.ChannelFeePermyriad), + ChannelMinimumFeeMsat: int64(node.nodeConfig.ChannelMinimumFeeMsat), + LspPubkey: node.publicKey.SerializeCompressed(), // TODO: Is the publicKey different from the ecies public key? + MaxInactiveDuration: int64(node.nodeConfig.MaxInactiveDuration), + OpeningFeeParamsMenu: params, + }, nil +} + +func (s *channelOpenerServer) createOpeningParamsMenu( + ctx context.Context, + node *node, + token string, +) ([]*lspdrpc.OpeningFeeParams, error) { + var menu []*lspdrpc.OpeningFeeParams + + settings, err := s.store.GetFeeParamsSettings(token) + if err != nil { + log.Printf("Failed to fetch fee params settings: %v", err) + return nil, fmt.Errorf("failed to get opening_fee_params") + } + + for _, setting := range settings { + validUntil := time.Now().UTC().Add(setting.Validity) + params := &lspdrpc.OpeningFeeParams{ + MinMsat: setting.Params.MinMsat, + Proportional: setting.Params.Proportional, + ValidUntil: validUntil.Format(basetypes.TIME_FORMAT), + MaxIdleTime: setting.Params.MaxIdleTime, + MaxClientToSelfDelay: setting.Params.MaxClientToSelfDelay, + } + + promise, err := createPromise(node, params) + if err != nil { + log.Printf("Failed to create promise: %v", err) + return nil, err + } + + params.Promise = *promise + menu = append(menu, params) + } + + sort.Slice(menu, func(i, j int) bool { + if menu[i].MinMsat == menu[j].MinMsat { + return menu[i].Proportional < menu[j].Proportional + } + + return menu[i].MinMsat < menu[j].MinMsat + }) + return menu, nil +} + +func paramsHash(params *lspdrpc.OpeningFeeParams) ([]byte, error) { + // First hash all the values in the params in a fixed order. + items := []interface{}{ + params.MinMsat, + params.Proportional, + params.ValidUntil, + params.MaxIdleTime, + params.MaxClientToSelfDelay, + } + blob, err := json.Marshal(items) + if err != nil { + log.Printf("paramsHash error: %v", err) + return nil, err + } + hash := sha256.Sum256(blob) + return hash[:], nil +} + +func createPromise(node *node, params *lspdrpc.OpeningFeeParams) (*string, error) { + hash, err := paramsHash(params) + if err != nil { + return nil, err + } + // Sign the hash with the private key of the LSP id. + sig, err := ecdsa.SignCompact(node.privateKey, hash[:], true) + if err != nil { + log.Printf("createPromise: SignCompact error: %v", err) + return nil, err + } + promise := hex.EncodeToString(sig) + return &promise, nil +} + +func verifyPromise(node *node, params *lspdrpc.OpeningFeeParams) error { + hash, err := paramsHash(params) + if err != nil { + return err + } + sig, err := hex.DecodeString(params.Promise) + if err != nil { + log.Printf("verifyPromise: hex.DecodeString error: %v", err) + return err + } + pub, _, err := ecdsa.RecoverCompact(sig, hash) + if err != nil { + log.Printf("verifyPromise: RecoverCompact(%x) error: %v", sig, err) + return err + } + if !node.publicKey.IsEqual(pub) { + log.Print("verifyPromise: not signed by us", err) + return fmt.Errorf("invalid promise") + } + return nil +} + +func validateOpeningFeeParams(node *node, params *lspdrpc.OpeningFeeParams) bool { + if params == nil { + return false + } + + err := verifyPromise(node, params) + if err != nil { + return false + } + + t, err := time.Parse(basetypes.TIME_FORMAT, params.ValidUntil) + if err != nil { + log.Printf("validateOpeningFeeParams: time.Parse(%v, %v) error: %v", basetypes.TIME_FORMAT, params.ValidUntil, err) + return false + } + + if time.Now().UTC().After(t) { + log.Printf("validateOpeningFeeParams: promise not valid anymore: %v", t) + return false + } + + return true +} + +func (s *channelOpenerServer) RegisterPayment( + ctx context.Context, + in *lspdrpc.RegisterPaymentRequest, +) (*lspdrpc.RegisterPaymentReply, error) { + node, token, err := s.getNode(ctx) + if err != nil { + return nil, err + } + + data, err := ecies.Decrypt(node.eciesPrivateKey, in.Blob) + if err != nil { + log.Printf("ecies.Decrypt(%x) error: %v", in.Blob, err) + data, err = btceclegacy.Decrypt(node.privateKey, in.Blob) + if err != nil { + log.Printf("btcec.Decrypt(%x) error: %v", in.Blob, err) + return nil, fmt.Errorf("btcec.Decrypt(%x) error: %w", in.Blob, err) + } + } + + var pi lspdrpc.PaymentInformation + err = proto.Unmarshal(data, &pi) + if err != nil { + log.Printf("proto.Unmarshal(%x) error: %v", data, err) + return nil, fmt.Errorf("proto.Unmarshal(%x) error: %w", data, err) + } + log.Printf("RegisterPayment - Destination: %x, pi.PaymentHash: %x, pi.PaymentSecret: %x, pi.IncomingAmountMsat: %v, pi.OutgoingAmountMsat: %v, pi.Tag: %v", + pi.Destination, pi.PaymentHash, pi.PaymentSecret, pi.IncomingAmountMsat, pi.OutgoingAmountMsat, pi.Tag) + + if len(pi.Tag) > 1000 { + return nil, fmt.Errorf("tag too long") + } + + if len(pi.Tag) != 0 { + var tag json.RawMessage + err = json.Unmarshal([]byte(pi.Tag), &tag) + if err != nil { + return nil, fmt.Errorf("tag is not a valid json object") + } + } + + // TODO: Remove this nil check and the else cluase when we enforce all + // clients to use opening_fee_params. + if pi.OpeningFeeParams != nil { + valid := validateOpeningFeeParams(node, pi.OpeningFeeParams) + if !valid { + return nil, fmt.Errorf("invalid opening_fee_params") + } + } else { + log.Printf("DEPRECATED: RegisterPayment with deprecated fee mechanism.") + pi.OpeningFeeParams = &lspdrpc.OpeningFeeParams{ + MinMsat: uint64(node.nodeConfig.ChannelMinimumFeeMsat), + Proportional: uint32(node.nodeConfig.ChannelFeePermyriad * 100), + ValidUntil: time.Now().UTC().Add(time.Duration(time.Hour * 24)).Format(basetypes.TIME_FORMAT), + MaxIdleTime: uint32(node.nodeConfig.MaxInactiveDuration / 600), + MaxClientToSelfDelay: uint32(10000), + } + } + + err = checkPayment(pi.OpeningFeeParams, pi.IncomingAmountMsat, pi.OutgoingAmountMsat) + if err != nil { + log.Printf("checkPayment(%v, %v) error: %v", pi.IncomingAmountMsat, pi.OutgoingAmountMsat, err) + return nil, fmt.Errorf("checkPayment(%v, %v) error: %v", pi.IncomingAmountMsat, pi.OutgoingAmountMsat, err) + } + params := &interceptor.OpeningFeeParams{ + MinMsat: pi.OpeningFeeParams.MinMsat, + Proportional: pi.OpeningFeeParams.Proportional, + ValidUntil: pi.OpeningFeeParams.ValidUntil, + MaxIdleTime: pi.OpeningFeeParams.MaxIdleTime, + MaxClientToSelfDelay: pi.OpeningFeeParams.MaxClientToSelfDelay, + Promise: pi.OpeningFeeParams.Promise, + } + err = s.store.RegisterPayment(token, params, pi.Destination, pi.PaymentHash, pi.PaymentSecret, pi.IncomingAmountMsat, pi.OutgoingAmountMsat, pi.Tag) + if err != nil { + log.Printf("RegisterPayment() error: %v", err) + return nil, fmt.Errorf("RegisterPayment() error: %w", err) + } + return &lspdrpc.RegisterPaymentReply{}, nil +} + +func (s *channelOpenerServer) OpenChannel(ctx context.Context, in *lspdrpc.OpenChannelRequest) (*lspdrpc.OpenChannelReply, error) { + node, _, err := s.getNode(ctx) + if err != nil { + return nil, err + } + + r, err, _ := node.openChannelReqGroup.Do(in.Pubkey, func() (interface{}, error) { + pubkey, err := hex.DecodeString(in.Pubkey) + if err != nil { + return nil, err + } + + channelCount, err := node.client.GetNodeChannelCount(pubkey) + if err != nil { + return nil, err + } + + var outPoint *wire.OutPoint + if channelCount == 0 { + outPoint, err = node.client.OpenChannel(&lightning.OpenChannelRequest{ + CapacitySat: node.nodeConfig.ChannelAmount, + Destination: pubkey, + TargetConf: &node.nodeConfig.TargetConf, + MinHtlcMsat: node.nodeConfig.MinHtlcMsat, + IsPrivate: node.nodeConfig.ChannelPrivate, + }) + + if err != nil { + log.Printf("Error in OpenChannel: %v", err) + return nil, err + } + + log.Printf("Response from OpenChannel: (TX: %v)", outPoint.String()) + } + + return &lspdrpc.OpenChannelReply{TxHash: outPoint.Hash.String(), OutputIndex: outPoint.Index}, nil + }) + + if err != nil { + return nil, err + } + return r.(*lspdrpc.OpenChannelReply), err +} + +func (n *node) getSignedEncryptedData(in *lspdrpc.Encrypted) (string, []byte, bool, error) { + usedEcies := true + signedBlob, err := ecies.Decrypt(n.eciesPrivateKey, in.Data) + if err != nil { + log.Printf("ecies.Decrypt(%x) error: %v", in.Data, err) + usedEcies = false + signedBlob, err = btceclegacy.Decrypt(n.privateKey, in.Data) + if err != nil { + log.Printf("btcec.Decrypt(%x) error: %v", in.Data, err) + return "", nil, usedEcies, fmt.Errorf("btcec.Decrypt(%x) error: %w", in.Data, err) + } + } + var signed lspdrpc.Signed + err = proto.Unmarshal(signedBlob, &signed) + if err != nil { + log.Printf("proto.Unmarshal(%x) error: %v", signedBlob, err) + return "", nil, usedEcies, fmt.Errorf("proto.Unmarshal(%x) error: %w", signedBlob, err) + } + pubkey, err := btcec.ParsePubKey(signed.Pubkey) + if err != nil { + log.Printf("unable to parse pubkey: %v", err) + return "", nil, usedEcies, fmt.Errorf("unable to parse pubkey: %w", err) + } + wireSig, err := lnwire.NewSigFromRawSignature(signed.Signature) + if err != nil { + return "", nil, usedEcies, fmt.Errorf("failed to decode signature: %v", err) + } + sig, err := wireSig.ToSignature() + if err != nil { + return "", nil, usedEcies, fmt.Errorf("failed to convert from wire format: %v", + err) + } + // The signature is over the sha256 hash of the message. + digest := chainhash.HashB(signed.Data) + if !sig.Verify(digest, pubkey) { + return "", nil, usedEcies, fmt.Errorf("invalid signature") + } + return hex.EncodeToString(signed.Pubkey), signed.Data, usedEcies, nil +} + +func (s *channelOpenerServer) CheckChannels(ctx context.Context, in *lspdrpc.Encrypted) (*lspdrpc.Encrypted, error) { + node, _, err := s.getNode(ctx) + if err != nil { + return nil, err + } + + nodeID, data, usedEcies, err := node.getSignedEncryptedData(in) + if err != nil { + log.Printf("getSignedEncryptedData error: %v", err) + return nil, fmt.Errorf("getSignedEncryptedData error: %v", err) + } + var checkChannelsRequest lspdrpc.CheckChannelsRequest + err = proto.Unmarshal(data, &checkChannelsRequest) + if err != nil { + log.Printf("proto.Unmarshal(%x) error: %v", data, err) + return nil, fmt.Errorf("proto.Unmarshal(%x) error: %w", data, err) + } + closedChannels, err := node.client.GetClosedChannels(nodeID, checkChannelsRequest.WaitingCloseChannels) + if err != nil { + log.Printf("GetClosedChannels(%v) error: %v", checkChannelsRequest.FakeChannels, err) + return nil, fmt.Errorf("GetClosedChannels(%v) error: %w", checkChannelsRequest.FakeChannels, err) + } + checkChannelsReply := lspdrpc.CheckChannelsReply{ + NotFakeChannels: make(map[string]uint64), + ClosedChannels: closedChannels, + } + dataReply, err := proto.Marshal(&checkChannelsReply) + if err != nil { + log.Printf("proto.Marshall() error: %v", err) + return nil, fmt.Errorf("proto.Marshal() error: %w", err) + } + pubkey, err := btcec.ParsePubKey(checkChannelsRequest.EncryptPubkey) + if err != nil { + log.Printf("unable to parse pubkey: %v", err) + return nil, fmt.Errorf("unable to parse pubkey: %w", err) + } + + var encrypted []byte + if usedEcies { + encrypted, err = ecies.Encrypt(node.eciesPublicKey, dataReply) + if err != nil { + log.Printf("ecies.Encrypt() error: %v", err) + return nil, fmt.Errorf("ecies.Encrypt() error: %w", err) + } + } else { + encrypted, err = btceclegacy.Encrypt(pubkey, dataReply) + if err != nil { + log.Printf("btcec.Encrypt() error: %v", err) + return nil, fmt.Errorf("btcec.Encrypt() error: %w", err) + } + } + + return &lspdrpc.Encrypted{Data: encrypted}, nil +} + +func (s *channelOpenerServer) getNode(ctx context.Context) (*node, string, error) { + nd := ctx.Value(contextKey("node")) + if nd == nil { + return nil, "", status.Errorf(codes.PermissionDenied, "Not authorized") + } + + nodeContext, ok := nd.(*nodeContext) + if !ok { + return nil, "", status.Errorf(codes.PermissionDenied, "Not authorized") + } + + return nodeContext.node, nodeContext.token, nil +} + +func checkPayment(params *lspdrpc.OpeningFeeParams, incomingAmountMsat, outgoingAmountMsat int64) error { + fees := incomingAmountMsat * int64(params.Proportional) / 1_000_000 / 1_000 * 1_000 + if fees < int64(params.MinMsat) { + fees = int64(params.MinMsat) + } + if incomingAmountMsat-outgoingAmountMsat < fees { + return fmt.Errorf("not enough fees") + } + return nil +} diff --git a/cln/cln_client.go b/cln/cln_client.go new file mode 100644 index 0000000..c80ffe0 --- /dev/null +++ b/cln/cln_client.go @@ -0,0 +1,282 @@ +package cln + +import ( + "encoding/hex" + "fmt" + "log" + "path/filepath" + "strings" + "time" + + "github.com/breez/lspd/basetypes" + "github.com/breez/lspd/lightning" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/elementsproject/glightning/glightning" + "golang.org/x/exp/slices" +) + +type ClnClient struct { + client *glightning.Lightning +} + +var ( + OPEN_STATUSES = []string{"CHANNELD_NORMAL"} + PENDING_STATUSES = []string{"OPENINGD", "CHANNELD_AWAITING_LOCKIN"} + CLOSING_STATUSES = []string{"CHANNELD_SHUTTING_DOWN", "CLOSINGD_SIGEXCHANGE", "CLOSINGD_COMPLETE", "AWAITING_UNILATERAL", "FUNDING_SPEND_SEEN", "ONCHAIN"} + CLOSED_STATUSES = []string{"CLOSED"} +) + +func NewClnClient(socketPath string) (*ClnClient, error) { + rpcFile := filepath.Base(socketPath) + if rpcFile == "" || rpcFile == "." { + return nil, fmt.Errorf("invalid socketPath '%s'", socketPath) + } + lightningDir := filepath.Dir(socketPath) + if lightningDir == "" || lightningDir == "." { + return nil, fmt.Errorf("invalid socketPath '%s'", socketPath) + } + + client := glightning.NewLightning() + client.SetTimeout(60) + client.StartUp(rpcFile, lightningDir) + return &ClnClient{ + client: client, + }, nil +} + +func (c *ClnClient) GetInfo() (*lightning.GetInfoResult, error) { + info, err := c.client.GetInfo() + if err != nil { + log.Printf("CLN: client.GetInfo() error: %v", err) + return nil, err + } + + return &lightning.GetInfoResult{ + Alias: info.Alias, + Pubkey: info.Id, + }, nil +} + +func (c *ClnClient) IsConnected(destination []byte) (bool, error) { + pubKey := hex.EncodeToString(destination) + peer, err := c.client.GetPeer(pubKey) + if err != nil { + if strings.Contains(err.Error(), "not found") { + return false, nil + } + + log.Printf("CLN: client.GetPeer(%v) error: %v", pubKey, err) + return false, fmt.Errorf("CLN: client.GetPeer(%v) error: %w", pubKey, err) + } + + if peer.Connected { + log.Printf("CLN: destination online: %x", destination) + return true, nil + } + + log.Printf("CLN: destination offline: %x", destination) + return false, nil +} + +func (c *ClnClient) OpenChannel(req *lightning.OpenChannelRequest) (*wire.OutPoint, error) { + pubkey := hex.EncodeToString(req.Destination) + var minConfs *uint16 + if req.MinConfs != nil { + m := uint16(*req.MinConfs) + minConfs = &m + } + var minDepth *uint16 + if req.IsZeroConf { + var d uint16 = 0 + minDepth = &d + } + + var rate *glightning.FeeRate + if req.FeeSatPerVByte != nil { + rate = &glightning.FeeRate{ + Rate: uint(*req.FeeSatPerVByte * 1000), + Style: glightning.PerKb, + } + } else if req.TargetConf != nil { + if *req.TargetConf < 3 { + rate = &glightning.FeeRate{ + Directive: glightning.Urgent, + } + } else if *req.TargetConf < 30 { + rate = &glightning.FeeRate{ + Directive: glightning.Normal, + } + } else { + rate = &glightning.FeeRate{ + Directive: glightning.Slow, + } + } + } + + fundResult, err := c.client.FundChannelExt( + pubkey, + glightning.NewSat(int(req.CapacitySat)), + rate, + !req.IsPrivate, + minConfs, + glightning.NewMsat(0), + minDepth, + glightning.NewMsat(0), + ) + + if err != nil { + log.Printf("CLN: client.FundChannelExt(%v, %v) error: %v", pubkey, req.CapacitySat, err) + return nil, err + } + + fundingTxId, err := chainhash.NewHashFromStr(fundResult.FundingTxId) + if err != nil { + log.Printf("CLN: chainhash.NewHashFromStr(%s) error: %v", fundResult.FundingTxId, err) + return nil, err + } + + channelPoint, err := basetypes.NewOutPoint(fundingTxId[:], uint32(fundResult.FundingTxOutputNum)) + if err != nil { + log.Printf("CLN: NewOutPoint(%s, %d) error: %v", fundingTxId.String(), fundResult.FundingTxOutputNum, err) + return nil, err + } + + return channelPoint, nil +} + +func (c *ClnClient) GetChannel(peerID []byte, channelPoint wire.OutPoint) (*lightning.GetChannelResult, error) { + pubkey := hex.EncodeToString(peerID) + peer, err := c.client.GetPeer(pubkey) + if err != nil { + log.Printf("CLN: client.GetPeer(%s) error: %v", pubkey, err) + return nil, err + } + + fundingTxID := channelPoint.Hash.String() + for _, c := range peer.Channels { + log.Printf("getChannel destination: %s, Short channel id: %v, local alias: %v , FundingTxID:%v, State:%v ", pubkey, c.ShortChannelId, c.Alias.Local, c.FundingTxId, c.State) + if slices.Contains(OPEN_STATUSES, c.State) && c.FundingTxId == fundingTxID { + confirmedChanID, err := basetypes.NewShortChannelIDFromString(c.ShortChannelId) + if err != nil { + fmt.Printf("NewShortChannelIDFromString %v error: %v", c.ShortChannelId, err) + return nil, err + } + initialChanID, err := basetypes.NewShortChannelIDFromString(c.Alias.Local) + if err != nil { + fmt.Printf("NewShortChannelIDFromString %v error: %v", c.Alias.Local, err) + return nil, err + } + return &lightning.GetChannelResult{ + InitialChannelID: *initialChanID, + ConfirmedChannelID: *confirmedChanID, + }, nil + } + } + + log.Printf("No channel found: getChannel(%v, %v)", pubkey, fundingTxID) + return nil, fmt.Errorf("no channel found") +} + +func (c *ClnClient) GetNodeChannelCount(nodeID []byte) (int, error) { + pubkey := hex.EncodeToString(nodeID) + peer, err := c.client.GetPeer(pubkey) + if err != nil { + log.Printf("CLN: client.GetPeer(%s) error: %v", pubkey, err) + return 0, err + } + + count := 0 + openPendingStatuses := append(OPEN_STATUSES, PENDING_STATUSES...) + for _, c := range peer.Channels { + if slices.Contains(openPendingStatuses, c.State) { + count++ + } + } + + return count, nil +} + +func (c *ClnClient) GetClosedChannels(nodeID string, channelPoints map[string]uint64) (map[string]uint64, error) { + r := make(map[string]uint64) + if len(channelPoints) == 0 { + return r, nil + } + + peer, err := c.client.GetPeer(nodeID) + if err != nil { + log.Printf("CLN: client.GetPeer(%s) error: %v", nodeID, err) + return nil, err + } + + lookup := make(map[string]uint64) + for _, c := range peer.Channels { + if slices.Contains(CLOSING_STATUSES, c.State) { + cid, err := basetypes.NewShortChannelIDFromString(c.ShortChannelId) + if err != nil { + log.Printf("CLN: GetClosedChannels NewShortChannelIDFromString(%v) error: %v", c.ShortChannelId, err) + continue + } + + outnum := uint64(*cid) & 0xFFFFFF + cp := fmt.Sprintf("%s:%d", c.FundingTxId, outnum) + lookup[cp] = uint64(*cid) + } + } + + for c, h := range channelPoints { + if _, ok := lookup[c]; !ok { + r[c] = h + } + } + + return r, nil +} + +func (c *ClnClient) GetPeerId(scid *basetypes.ShortChannelID) ([]byte, error) { + scidStr := scid.ToString() + peers, err := c.client.ListPeers() + if err != nil { + return nil, err + } + + var dest *string + for _, p := range peers { + for _, ch := range p.Channels { + if ch.Alias.Local == scidStr || + ch.Alias.Remote == scidStr || + ch.ShortChannelId == scidStr { + dest = &p.Id + break + } + } + } + + if dest == nil { + return nil, nil + } + + return hex.DecodeString(*dest) +} + +var pollingInterval = 400 * time.Millisecond + +func (c *ClnClient) WaitOnline(peerID []byte, deadline time.Time) error { + peerIDStr := hex.EncodeToString(peerID) + for { + peer, err := c.client.GetPeer(peerIDStr) + if err == nil && peer.Connected { + return nil + } + + select { + case <-time.After(time.Until(deadline)): + return fmt.Errorf("timeout") + case <-time.After(pollingInterval): + } + } +} + +func (c *ClnClient) WaitChannelActive(peerID []byte, deadline time.Time) error { + return nil +} diff --git a/cln/cln_interceptor.go b/cln/cln_interceptor.go new file mode 100644 index 0000000..6f3e4b1 --- /dev/null +++ b/cln/cln_interceptor.go @@ -0,0 +1,309 @@ +package cln + +import ( + "bytes" + "context" + "encoding/hex" + "fmt" + "io" + "log" + "sync" + "time" + + "github.com/breez/lspd/basetypes" + "github.com/breez/lspd/cln_plugin/proto" + "github.com/breez/lspd/config" + "github.com/breez/lspd/interceptor" + sphinx "github.com/lightningnetwork/lightning-onion" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/record" + "github.com/lightningnetwork/lnd/tlv" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/keepalive" + "google.golang.org/grpc/status" +) + +type ClnHtlcInterceptor struct { + interceptor *interceptor.Interceptor + config *config.NodeConfig + pluginAddress string + client *ClnClient + pluginClient proto.ClnPluginClient + initWg sync.WaitGroup + doneWg sync.WaitGroup + stopRequested bool + ctx context.Context + cancel context.CancelFunc +} + +func NewClnHtlcInterceptor(conf *config.NodeConfig, client *ClnClient, interceptor *interceptor.Interceptor) (*ClnHtlcInterceptor, error) { + i := &ClnHtlcInterceptor{ + config: conf, + pluginAddress: conf.Cln.PluginAddress, + client: client, + interceptor: interceptor, + } + + i.initWg.Add(1) + return i, nil +} + +func (i *ClnHtlcInterceptor) Start() error { + ctx, cancel := context.WithCancel(context.Background()) + log.Printf("Dialing cln plugin on '%s'", i.pluginAddress) + conn, err := grpc.DialContext( + ctx, + i.pluginAddress, + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithKeepaliveParams(keepalive.ClientParameters{ + Time: time.Duration(10) * time.Second, + Timeout: time.Duration(10) * time.Second, + }), + ) + if err != nil { + log.Printf("grpc.Dial error: %v", err) + cancel() + return err + } + + i.pluginClient = proto.NewClnPluginClient(conn) + i.ctx = ctx + i.cancel = cancel + i.stopRequested = false + return i.intercept() +} + +func (i *ClnHtlcInterceptor) intercept() error { + inited := false + + defer func() { + if !inited { + i.initWg.Done() + } + log.Printf("CLN intercept(): stopping. Waiting for in-progress interceptions to complete.") + i.doneWg.Wait() + }() + + for { + if i.ctx.Err() != nil { + return i.ctx.Err() + } + + log.Printf("Connecting CLN HTLC interceptor.") + interceptorClient, err := i.pluginClient.HtlcStream(i.ctx) + if err != nil { + log.Printf("pluginClient.HtlcStream(): %v", err) + <-time.After(time.Second) + continue + } + + for { + if i.ctx.Err() != nil { + return i.ctx.Err() + } + + if !inited { + inited = true + i.initWg.Done() + } + + // Stop receiving if stop if requested. The defer func on top of this + // function will assure all htlcs that are currently being processed + // will complete. + if i.stopRequested { + return nil + } + + request, err := interceptorClient.Recv() + if err != nil { + // If it is just the error result of the context cancellation + // the we exit silently. + status, ok := status.FromError(err) + if ok && status.Code() == codes.Canceled { + log.Printf("Got code canceled. Break.") + break + } + + // Otherwise it an unexpected error, we fail the test. + log.Printf("unexpected error in interceptor.Recv() %v", err) + break + } + + i.doneWg.Add(1) + go func() { + paymentHash, err := hex.DecodeString(request.Htlc.PaymentHash) + if err != nil { + interceptorClient.Send(i.defaultResolution(request)) + i.doneWg.Done() + return + } + + scid, err := basetypes.NewShortChannelIDFromString(request.Onion.ShortChannelId) + if err != nil { + interceptorClient.Send(i.defaultResolution(request)) + i.doneWg.Done() + return + } + + interceptResult := i.interceptor.Intercept(scid, paymentHash, request.Onion.ForwardMsat, request.Onion.OutgoingCltvValue, request.Htlc.CltvExpiry) + switch interceptResult.Action { + case interceptor.INTERCEPT_RESUME_WITH_ONION: + interceptorClient.Send(i.resumeWithOnion(request, interceptResult)) + case interceptor.INTERCEPT_FAIL_HTLC_WITH_CODE: + interceptorClient.Send( + i.failWithCode(request, interceptResult.FailureCode), + ) + case interceptor.INTERCEPT_RESUME: + fallthrough + default: + interceptorClient.Send( + i.defaultResolution(request), + ) + } + + i.doneWg.Done() + }() + } + + <-time.After(time.Second) + } +} + +func (i *ClnHtlcInterceptor) Stop() error { + // Setting stopRequested to true will make the interceptor stop receiving. + i.stopRequested = true + + // Wait until all already received htlcs are handled, responses sent back. + i.doneWg.Wait() + + // Close the grpc connection. + i.cancel() + return nil +} + +func (i *ClnHtlcInterceptor) WaitStarted() { + i.initWg.Wait() +} + +func (i *ClnHtlcInterceptor) resumeWithOnion(request *proto.HtlcAccepted, interceptResult interceptor.InterceptResult) *proto.HtlcResolution { + //decoding and encoding onion with alias in type 6 record. + payload, err := hex.DecodeString(request.Onion.Payload) + if err != nil { + log.Printf("resumeWithOnion: hex.DecodeString(%v) error: %v", request.Onion.Payload, err) + return i.failWithCode(request, interceptor.FAILURE_TEMPORARY_CHANNEL_FAILURE) + } + newPayload, err := encodePayloadWithNextHop(payload, interceptResult.ChannelId, interceptResult.AmountMsat) + if err != nil { + log.Printf("encodePayloadWithNextHop error: %v", err) + return i.failWithCode(request, interceptor.FAILURE_TEMPORARY_CHANNEL_FAILURE) + } + + newPayloadStr := hex.EncodeToString(newPayload) + + chanId := lnwire.NewChanIDFromOutPoint(interceptResult.ChannelPoint).String() + log.Printf("forwarding htlc to the destination node and a new private channel was opened") + return &proto.HtlcResolution{ + Correlationid: request.Correlationid, + Outcome: &proto.HtlcResolution_Continue{ + Continue: &proto.HtlcContinue{ + ForwardTo: &chanId, + Payload: &newPayloadStr, + }, + }, + } +} + +func (i *ClnHtlcInterceptor) defaultResolution(request *proto.HtlcAccepted) *proto.HtlcResolution { + return &proto.HtlcResolution{ + Correlationid: request.Correlationid, + Outcome: &proto.HtlcResolution_Continue{ + Continue: &proto.HtlcContinue{}, + }, + } +} + +func (i *ClnHtlcInterceptor) failWithCode(request *proto.HtlcAccepted, code interceptor.InterceptFailureCode) *proto.HtlcResolution { + return &proto.HtlcResolution{ + Correlationid: request.Correlationid, + Outcome: &proto.HtlcResolution_Fail{ + Fail: &proto.HtlcFail{ + Failure: &proto.HtlcFail_FailureMessage{ + FailureMessage: i.mapFailureCode(code), + }, + }, + }, + } +} + +func encodePayloadWithNextHop(payload []byte, channelId uint64, amountToForward uint64) ([]byte, error) { + bufReader := bytes.NewBuffer(payload) + var b [8]byte + varInt, err := sphinx.ReadVarInt(bufReader, &b) + if err != nil { + return nil, fmt.Errorf("failed to read payload length %x: %v", payload, err) + } + + innerPayload := make([]byte, varInt) + if _, err := io.ReadFull(bufReader, innerPayload[:]); err != nil { + return nil, fmt.Errorf("failed to decode payload %x: %v", innerPayload[:], err) + } + + s, _ := tlv.NewStream() + tlvMap, err := s.DecodeWithParsedTypes(bytes.NewReader(innerPayload)) + if err != nil { + return nil, fmt.Errorf("DecodeWithParsedTypes failed for %x: %v", innerPayload[:], err) + } + + tt := record.NewNextHopIDRecord(&channelId) + ttbuf := bytes.NewBuffer([]byte{}) + if err := tt.Encode(ttbuf); err != nil { + return nil, fmt.Errorf("failed to encode nexthop %x: %v", innerPayload[:], err) + } + + amt := record.NewAmtToFwdRecord(&amountToForward) + amtbuf := bytes.NewBuffer([]byte{}) + if err := amt.Encode(amtbuf); err != nil { + return nil, fmt.Errorf("failed to encode AmtToFwd %x: %v", innerPayload[:], err) + } + + uTlvMap := make(map[uint64][]byte) + for t, b := range tlvMap { + if t == record.NextHopOnionType { + uTlvMap[uint64(t)] = ttbuf.Bytes() + continue + } + + if t == record.AmtOnionType { + uTlvMap[uint64(t)] = amtbuf.Bytes() + continue + } + uTlvMap[uint64(t)] = b + } + tlvRecords := tlv.MapToRecords(uTlvMap) + s, err = tlv.NewStream(tlvRecords...) + if err != nil { + return nil, fmt.Errorf("tlv.NewStream(%x) error: %v", tlvRecords, err) + } + var newPayloadBuf bytes.Buffer + err = s.Encode(&newPayloadBuf) + if err != nil { + return nil, fmt.Errorf("encode error: %v", err) + } + return newPayloadBuf.Bytes(), nil +} + +func (i *ClnHtlcInterceptor) mapFailureCode(original interceptor.InterceptFailureCode) string { + switch original { + case interceptor.FAILURE_TEMPORARY_CHANNEL_FAILURE: + return "1007" + case interceptor.FAILURE_TEMPORARY_NODE_FAILURE: + return "2002" + case interceptor.FAILURE_INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS: + return "400F" + default: + log.Printf("Unknown failure code %v, default to temporary channel failure.", original) + return "1007" // temporary channel failure + } +} diff --git a/cln_plugin/channel_acceptor.go b/cln_plugin/channel_acceptor.go new file mode 100644 index 0000000..8bb969e --- /dev/null +++ b/cln_plugin/channel_acceptor.go @@ -0,0 +1,44 @@ +package cln_plugin + +import ( + "encoding/json" + "fmt" + + sj "go.starlark.net/lib/json" + "go.starlark.net/starlark" +) + +func channelAcceptor(acceptScript string, method string, openChannel json.RawMessage) (json.RawMessage, error) { + reject, _ := json.Marshal(struct { + Result string `json:"result"` + }{Result: "reject"}) + accept, _ := json.Marshal(struct { + Result string `json:"result"` + }{Result: "continue"}) + + if acceptScript == "" { + return accept, nil + } + + sd := starlark.StringDict{ + "method": starlark.String(method), + "openchannel": starlark.String(openChannel), + } + for _, k := range sj.Module.Members.Keys() { + sd[k] = sj.Module.Members[k] + } + value, err := starlark.Eval( + &starlark.Thread{}, + "", + acceptScript, + sd, + ) + if err != nil { + return reject, err + } + s, ok := value.(starlark.String) + if !ok { + return reject, fmt.Errorf("not a string") + } + return json.RawMessage(s.GoString()), nil +} diff --git a/cln_plugin/cln_messages.go b/cln_plugin/cln_messages.go new file mode 100644 index 0000000..aed6a5e --- /dev/null +++ b/cln_plugin/cln_messages.go @@ -0,0 +1,117 @@ +package cln_plugin + +import ( + "encoding/json" +) + +type Request struct { + Id json.RawMessage `json:"id,omitempty"` + Method string `json:"method"` + JsonRpc string `json:"jsonrpc"` + Params json.RawMessage `json:"params,omitempty"` +} + +type Response struct { + Id json.RawMessage `json:"id"` + JsonRpc string `json:"jsonrpc"` + Result Result `json:"result,omitempty"` + Error *RpcError `json:"error,omitempty"` +} + +type Result interface{} + +type RpcError struct { + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data,omitempty"` +} + +type Manifest struct { + Options []Option `json:"options"` + RpcMethods []*RpcMethod `json:"rpcmethods"` + Dynamic bool `json:"dynamic"` + Subscriptions []string `json:"subscriptions,omitempty"` + Hooks []Hook `json:"hooks,omitempty"` + FeatureBits *FeatureBits `json:"featurebits,omitempty"` + NonNumericIds bool `json:"nonnumericids"` +} + +type Option struct { + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description"` + Default *string `json:"default,omitempty"` + Multi *bool `json:"multi,omitempty"` + Deprecated *bool `json:"deprecated,omitempty"` +} + +type RpcMethod struct { + Name string `json:"name"` + Usage string `json:"usage"` + Description string `json:"description"` + LongDescription *string `json:"long_description,omitempty"` + Deprecated *bool `json:"deprecated,omitempty"` +} + +type Hook struct { + Name string `json:"name"` + Before []string `json:"before,omitempty"` +} + +type FeatureBits struct { + Node *string `json:"node,omitempty"` + Init *string `json:"init,omitempty"` + Channel *string `json:"channel,omitempty"` + Invoice *string `json:"invoice,omitempty"` +} + +type InitMessage struct { + Options map[string]interface{} `json:"options,omitempty"` + Configuration *InitConfiguration `json:"configuration,omitempty"` +} + +type InitConfiguration struct { + LightningDir string `json:"lightning-dir"` + RpcFile string `json:"rpc-file"` + Startup bool `json:"startup"` + Network string `json:"network"` + FeatureSet *FeatureBits `json:"feature_set"` + Proxy *Proxy `json:"proxy"` + TorV3Enabled bool `json:"torv3-enabled"` + AlwaysUseProxy bool `json:"always_use_proxy"` +} + +type Proxy struct { + Type string `json:"type"` + Address string `json:"address"` + Port int `json:"port"` +} + +type HtlcAccepted struct { + Onion *Onion `json:"onion"` + Htlc *Htlc `json:"htlc"` + ForwardTo string `json:"forward_to"` +} + +type Onion struct { + Payload string `json:"payload"` + ShortChannelId string `json:"short_channel_id"` + ForwardMsat uint64 `json:"forward_msat"` + OutgoingCltvValue uint32 `json:"outgoing_cltv_value"` + SharedSecret string `json:"shared_secret"` + NextOnion string `json:"next_onion"` +} + +type Htlc struct { + ShortChannelId string `json:"short_channel_id"` + Id uint64 `json:"id"` + AmountMsat uint64 `json:"amount_msat"` + CltvExpiry uint32 `json:"cltv_expiry"` + CltvExpiryRelative uint32 `json:"cltv_expiry_relative"` + PaymentHash string `json:"payment_hash"` +} + +type LogNotification struct { + Level string `json:"level"` + Message string `json:"message"` +} diff --git a/cln_plugin/cln_plugin.go b/cln_plugin/cln_plugin.go new file mode 100644 index 0000000..778fa33 --- /dev/null +++ b/cln_plugin/cln_plugin.go @@ -0,0 +1,622 @@ +// The code in this plugin is highly inspired by and sometimes copied from +// github.com/niftynei/glightning. Therefore pieces of this code are subject +// to Copyright Lisa Neigut (Blockstream) 2019. + +package cln_plugin + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "log" + "os" + "strings" + "sync" + "time" +) + +const ( + SubscriberTimeoutOption = "lsp-subscribertimeout" + ListenAddressOption = "lsp-listen" + channelAcceptScript = "lsp-channel-accept-script" +) + +var ( + DefaultSubscriberTimeout = "1m" + DefaultChannelAcceptorScript = "" +) + +const ( + MaxIntakeBuffer = 500 * 1024 * 1023 +) + +const ( + SpecVersion = "2.0" + ParseError = -32700 + InvalidRequest = -32600 + MethodNotFound = -32601 + InvalidParams = -32603 + InternalErr = -32603 +) + +var ( + TwoNewLines = []byte("\n\n") +) + +type ClnPlugin struct { + done chan struct{} + server *server + in *os.File + out *bufio.Writer + writeMtx sync.Mutex + channelAcceptScript string +} + +func NewClnPlugin(in, out *os.File) *ClnPlugin { + c := &ClnPlugin{ + done: make(chan struct{}), + in: in, + out: bufio.NewWriter(out), + } + + return c +} + +// Starts the cln plugin. +// NOTE: The grpc server is started in the handleInit function. +func (c *ClnPlugin) Start() { + c.setupLogging() + go c.listenRequests() + <-c.done + s := c.server + if s != nil { + <-s.completed + } +} + +// Stops the cln plugin. Drops any remaining work immediately. +// Pending htlcs will be replayed when cln starts again. +func (c *ClnPlugin) Stop() { + log.Printf("Stop called. Stopping plugin.") + close(c.done) + + s := c.server + if s != nil { + s.Stop() + } +} + +// listens stdout for requests from cln and sends the requests to the +// appropriate handler in fifo order. +func (c *ClnPlugin) listenRequests() error { + scanner := bufio.NewScanner(c.in) + buf := make([]byte, 1024) + scanner.Buffer(buf, MaxIntakeBuffer) + + // cln messages are split by a double newline. + scanner.Split(scanDoubleNewline) + for { + select { + case <-c.done: + return nil + default: + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + log.Fatal(err) + return err + } + + return nil + } + + msg := scanner.Bytes() + + // Always log the message json + log.Println(string(msg)) + + // pass down a copy so things stay sane + msg_buf := make([]byte, len(msg)) + copy(msg_buf, msg) + + // NOTE: processMsg is synchronous, so it should only do quick work. + c.processMsg(msg_buf) + } + } +} + +// Listens to responses to htlc_accepted requests from the grpc server. +func (c *ClnPlugin) listenServer() { + for { + select { + case <-c.done: + return + default: + id, result := c.server.Receive() + + // The server may return nil if it is stopped. + if result == nil { + continue + } + + serid, _ := json.Marshal(&id) + c.sendToCln(&Response{ + Id: serid, + JsonRpc: SpecVersion, + Result: result, + }) + } + } +} + +// processes a single message from cln. Sends the message to the appropriate +// handler. +func (c *ClnPlugin) processMsg(msg []byte) { + if len(msg) == 0 { + c.sendError(nil, InvalidRequest, "Got an invalid zero length request") + return + } + + // Handle request batches. + if msg[0] == '[' { + var requests []*Request + err := json.Unmarshal(msg, &requests) + if err != nil { + c.sendError( + nil, + ParseError, + fmt.Sprintf("Failed to unmarshal request batch: %v", err), + ) + return + } + + for _, request := range requests { + c.processRequest(request) + } + + return + } + + // Parse the received buffer into a request object. + var request Request + err := json.Unmarshal(msg, &request) + if err != nil { + c.sendError( + nil, + ParseError, + fmt.Sprintf("failed to unmarshal request: %v", err), + ) + return + } + + c.processRequest(&request) +} + +func (c *ClnPlugin) processRequest(request *Request) { + // Make sure the spec version is expected. + if request.JsonRpc != SpecVersion { + c.sendError(request.Id, InvalidRequest, fmt.Sprintf( + `Invalid jsonrpc, expected '%s' got '%s'`, + SpecVersion, + request.JsonRpc, + )) + return + } + + // Send the message to the appropriate handler. + switch request.Method { + case "getmanifest": + c.handleGetManifest(request) + case "init": + c.handleInit(request) + case "shutdown": + c.handleShutdown(request) + case "htlc_accepted": + c.handleHtlcAccepted(request) + case "openchannel": + // handle open channel in a goroutine, because order doesn't matter. + go c.handleOpenChannel(request) + case "openchannel2": + // handle open channel in a goroutine, because order doesn't matter. + go c.handleOpenChannel(request) + case "getchannelacceptscript": + c.sendToCln(&Response{ + JsonRpc: SpecVersion, + Id: request.Id, + Result: c.channelAcceptScript, + }) + case "setchannelacceptscript": + c.handleSetChannelAcceptScript(request) + default: + c.sendError( + request.Id, + MethodNotFound, + fmt.Sprintf("Method '%s' not found", request.Method), + ) + } +} + +// Returns this plugin's manifest to cln. +func (c *ClnPlugin) handleGetManifest(request *Request) { + c.sendToCln(&Response{ + Id: request.Id, + JsonRpc: SpecVersion, + Result: &Manifest{ + Options: []Option{ + { + Name: ListenAddressOption, + Type: "string", + Description: "listen address for the htlc_accepted lsp " + + "grpc server", + }, + { + Name: SubscriberTimeoutOption, + Type: "string", + Description: "the maximum duration we will hold a htlc " + + "if no subscriber is active. golang duration string.", + Default: &DefaultSubscriberTimeout, + }, + { + Name: channelAcceptScript, + Type: "string", + Description: "starlark script for channel acceptor.", + Default: &DefaultChannelAcceptorScript, + }, + }, + RpcMethods: []*RpcMethod{ + { + Name: "getchannelacceptscript", + Description: "Get the startlark channel acceptor script", + }, + { + Name: "setchannelacceptscript", + Description: "Set the startlark channel acceptor script", + }, + }, + Dynamic: true, + Hooks: []Hook{ + {Name: "htlc_accepted"}, + {Name: "openchannel"}, + {Name: "openchannel2"}, + }, + NonNumericIds: true, + Subscriptions: []string{ + "shutdown", + }, + }, + }) +} + +// Handles plugin initialization. Parses startup options and starts the grpc +// server. +func (c *ClnPlugin) handleInit(request *Request) { + // Deserialize the init message. + var initMsg InitMessage + err := json.Unmarshal(request.Params, &initMsg) + if err != nil { + c.sendError( + request.Id, + ParseError, + fmt.Sprintf("Failed to unmarshal init params: %v", err), + ) + return + } + + // Get the channel acceptor script option. + sc, ok := initMsg.Options[channelAcceptScript] + if !ok { + c.sendError( + request.Id, + InvalidParams, + fmt.Sprintf("Missing option '%s'", channelAcceptScript), + ) + return + } + + c.channelAcceptScript, ok = sc.(string) + if !ok { + c.sendError( + request.Id, + InvalidParams, + fmt.Sprintf( + "Invalid value '%v' for option '%s'", + sc, + channelAcceptScript, + ), + ) + return + } + + // Get the listen address option. + l, ok := initMsg.Options[ListenAddressOption] + if !ok { + c.sendError( + request.Id, + InvalidParams, + fmt.Sprintf("Missing option '%s'", ListenAddressOption), + ) + return + } + + addr, ok := l.(string) + if !ok || addr == "" { + c.sendError( + request.Id, + InvalidParams, + fmt.Sprintf( + "Invalid value '%v' for option '%s'", + l, + ListenAddressOption, + ), + ) + return + } + + // Get the subscriber timeout option. + t, ok := initMsg.Options[SubscriberTimeoutOption] + if !ok { + c.sendError( + request.Id, + InvalidParams, + fmt.Sprintf("Missing option '%s'", SubscriberTimeoutOption), + ) + return + } + + s, ok := t.(string) + if !ok || s == "" { + c.sendError( + request.Id, + InvalidParams, + fmt.Sprintf( + "Invalid value '%v' for option '%s'", + t, + SubscriberTimeoutOption, + ), + ) + return + } + + subscriberTimeout, err := time.ParseDuration(s) + if err != nil { + c.sendError( + request.Id, + InvalidParams, + fmt.Sprintf( + "Invalid value '%v' for option '%s'", + s, + SubscriberTimeoutOption, + ), + ) + return + } + + // Start the grpc server. + c.server = NewServer(addr, subscriberTimeout) + go c.server.Start() + err = c.server.WaitStarted() + if err != nil { + c.sendError( + request.Id, + InternalErr, + fmt.Sprintf("Failed to start server: %s", err.Error()), + ) + return + } + + // Listen for responses from the grpc server. + go c.listenServer() + + // Let cln know the plugin is initialized. + c.sendToCln(&Response{ + Id: request.Id, + JsonRpc: SpecVersion, + }) +} + +// Handles the shutdown message. Stops any work immediately. +func (c *ClnPlugin) handleShutdown(request *Request) { + c.Stop() +} + +// Sends a htlc_accepted message to the grpc server. +func (c *ClnPlugin) handleHtlcAccepted(request *Request) { + var htlc HtlcAccepted + err := json.Unmarshal(request.Params, &htlc) + if err != nil { + c.sendError( + request.Id, + ParseError, + fmt.Sprintf( + "Failed to unmarshal htlc_accepted params:%s [%s]", + err.Error(), + request.Params, + ), + ) + return + } + + c.server.Send(idToString(request.Id), &htlc) +} + +func (c *ClnPlugin) handleSetChannelAcceptScript(request *Request) { + var params []string + err := json.Unmarshal(request.Params, ¶ms) + if err != nil { + c.sendError( + request.Id, + ParseError, + fmt.Sprintf( + "Failed to unmarshal setchannelacceptscript params:%s [%s]", + err.Error(), + request.Params, + ), + ) + return + } + if len(params) >= 1 { + c.channelAcceptScript = params[0] + } + c.sendToCln(&Response{ + JsonRpc: SpecVersion, + Id: request.Id, + Result: c.channelAcceptScript, + }) +} + +func unmarshalOpenChannel(request *Request) (r json.RawMessage, err error) { + switch request.Method { + case "openchannel": + var openChannel struct { + OpenChannel json.RawMessage `json:"openchannel"` + } + err = json.Unmarshal(request.Params, &openChannel) + if err != nil { + return + } + r = openChannel.OpenChannel + case "openchannel2": + var openChannel struct { + OpenChannel json.RawMessage `json:"openchannel2"` + } + err = json.Unmarshal(request.Params, &openChannel) + if err != nil { + return + } + r = openChannel.OpenChannel + } + return r, nil +} +func (c *ClnPlugin) handleOpenChannel(request *Request) { + p, err := unmarshalOpenChannel(request) + if err != nil { + c.sendError( + request.Id, + ParseError, + fmt.Sprintf( + "Failed to unmarshal openchannel params:%s [%s]", + err.Error(), + request.Params, + ), + ) + return + } + result, err := channelAcceptor(c.channelAcceptScript, request.Method, p) + if err != nil { + log.Printf("channelAcceptor error - request: %s error: %v", request, err) + } + c.sendToCln(&Response{ + JsonRpc: SpecVersion, + Id: request.Id, + Result: result, + }) +} + +// Sends an error to cln. +func (c *ClnPlugin) sendError(id json.RawMessage, code int, message string) { + // Log the error to cln first. + c.log("error", message) + + // Then create an error message. + resp := &Response{ + JsonRpc: SpecVersion, + Error: &RpcError{ + Code: code, + Message: message, + }, + } + + if len(id) > 0 { + resp.Id = id + } + + c.sendToCln(resp) +} + +// Sends a message to cln. +func (c *ClnPlugin) sendToCln(msg interface{}) { + c.writeMtx.Lock() + defer c.writeMtx.Unlock() + + data, err := json.Marshal(msg) + if err != nil { + log.Printf("Failed to marshal message for cln, ignoring message: %+v", msg) + return + } + + data = append(data, TwoNewLines...) + c.out.Write(data) + c.out.Flush() +} + +func (c *ClnPlugin) setupLogging() { + in, out := io.Pipe() + log.SetFlags(log.Ltime | log.Lshortfile) + log.SetOutput(out) + go func(in io.Reader) { + // everytime we get a new message, log it thru c-lightning + scanner := bufio.NewScanner(in) + for { + select { + case <-c.done: + return + default: + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + log.Fatalf( + "can't print out to std err, killing: %v", + err, + ) + } + } + + for _, line := range strings.Split(scanner.Text(), "\n") { + c.log("info", line) + } + } + } + + }(in) +} + +func (c *ClnPlugin) log(level string, message string) { + params, _ := json.Marshal(&LogNotification{ + Level: level, + Message: message, + }) + + c.sendToCln(&Request{ + Method: "log", + JsonRpc: SpecVersion, + Params: params, + }) +} + +// Helper method for the bufio scanner to split messages on double newlines. +func scanDoubleNewline( + data []byte, + atEOF bool, +) (advance int, token []byte, err error) { + for i := 0; i < len(data); i++ { + if data[i] == '\n' && (i+1) < len(data) && data[i+1] == '\n' { + return i + 2, data[:i], nil + } + } + // this trashes anything left over in + // the buffer if we're at EOF, with no /n/n present + return 0, nil, nil +} + +// converts a raw cln id to string. The CLN id can either be an integer or a +// string. if it's a string, the quotes are removed. +func idToString(id json.RawMessage) string { + if len(id) == 0 { + return "" + } + + str := string(id) + str = strings.TrimSpace(str) + str = strings.Trim(str, "\"") + str = strings.Trim(str, "'") + return str +} diff --git a/cln_plugin/cmd/main.go b/cln_plugin/cmd/main.go new file mode 100644 index 0000000..d358eb5 --- /dev/null +++ b/cln_plugin/cmd/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "os" + "os/signal" + "syscall" + + "github.com/breez/lspd/cln_plugin" +) + +func main() { + plugin := cln_plugin.NewClnPlugin(os.Stdin, os.Stdout) + c := make(chan os.Signal, 1) + signal.Notify(c, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-c + // Stop everything gracefully on stop signal + plugin.Stop() + }() + plugin.Start() +} diff --git a/cln_plugin/genproto.sh b/cln_plugin/genproto.sh new file mode 100755 index 0000000..e598c4e --- /dev/null +++ b/cln_plugin/genproto.sh @@ -0,0 +1,5 @@ +#!/bin/bash +SCRIPTDIR=$(dirname $0) +PROTO_ROOT=$SCRIPTDIR/proto + +protoc --go_out=$PROTO_ROOT --go_opt=paths=source_relative --go-grpc_out=$PROTO_ROOT --go-grpc_opt=paths=source_relative -I=$PROTO_ROOT $PROTO_ROOT/*.proto \ No newline at end of file diff --git a/cln_plugin/proto/cln_plugin.pb.go b/cln_plugin/proto/cln_plugin.pb.go new file mode 100644 index 0000000..72675d6 --- /dev/null +++ b/cln_plugin/proto/cln_plugin.pb.go @@ -0,0 +1,787 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.28.1 +// protoc v3.21.12 +// source: cln_plugin.proto + +package proto + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type HtlcAccepted struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Correlationid string `protobuf:"bytes,1,opt,name=correlationid,proto3" json:"correlationid,omitempty"` + Onion *Onion `protobuf:"bytes,2,opt,name=onion,proto3" json:"onion,omitempty"` + Htlc *Htlc `protobuf:"bytes,3,opt,name=htlc,proto3" json:"htlc,omitempty"` + ForwardTo string `protobuf:"bytes,4,opt,name=forward_to,json=forwardTo,proto3" json:"forward_to,omitempty"` +} + +func (x *HtlcAccepted) Reset() { + *x = HtlcAccepted{} + if protoimpl.UnsafeEnabled { + mi := &file_cln_plugin_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *HtlcAccepted) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HtlcAccepted) ProtoMessage() {} + +func (x *HtlcAccepted) ProtoReflect() protoreflect.Message { + mi := &file_cln_plugin_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HtlcAccepted.ProtoReflect.Descriptor instead. +func (*HtlcAccepted) Descriptor() ([]byte, []int) { + return file_cln_plugin_proto_rawDescGZIP(), []int{0} +} + +func (x *HtlcAccepted) GetCorrelationid() string { + if x != nil { + return x.Correlationid + } + return "" +} + +func (x *HtlcAccepted) GetOnion() *Onion { + if x != nil { + return x.Onion + } + return nil +} + +func (x *HtlcAccepted) GetHtlc() *Htlc { + if x != nil { + return x.Htlc + } + return nil +} + +func (x *HtlcAccepted) GetForwardTo() string { + if x != nil { + return x.ForwardTo + } + return "" +} + +type Onion struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Payload string `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"` + ShortChannelId string `protobuf:"bytes,2,opt,name=short_channel_id,json=shortChannelId,proto3" json:"short_channel_id,omitempty"` + ForwardMsat uint64 `protobuf:"varint,3,opt,name=forward_msat,json=forwardMsat,proto3" json:"forward_msat,omitempty"` + OutgoingCltvValue uint32 `protobuf:"varint,4,opt,name=outgoing_cltv_value,json=outgoingCltvValue,proto3" json:"outgoing_cltv_value,omitempty"` + SharedSecret string `protobuf:"bytes,5,opt,name=shared_secret,json=sharedSecret,proto3" json:"shared_secret,omitempty"` + NextOnion string `protobuf:"bytes,6,opt,name=next_onion,json=nextOnion,proto3" json:"next_onion,omitempty"` +} + +func (x *Onion) Reset() { + *x = Onion{} + if protoimpl.UnsafeEnabled { + mi := &file_cln_plugin_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Onion) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Onion) ProtoMessage() {} + +func (x *Onion) ProtoReflect() protoreflect.Message { + mi := &file_cln_plugin_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Onion.ProtoReflect.Descriptor instead. +func (*Onion) Descriptor() ([]byte, []int) { + return file_cln_plugin_proto_rawDescGZIP(), []int{1} +} + +func (x *Onion) GetPayload() string { + if x != nil { + return x.Payload + } + return "" +} + +func (x *Onion) GetShortChannelId() string { + if x != nil { + return x.ShortChannelId + } + return "" +} + +func (x *Onion) GetForwardMsat() uint64 { + if x != nil { + return x.ForwardMsat + } + return 0 +} + +func (x *Onion) GetOutgoingCltvValue() uint32 { + if x != nil { + return x.OutgoingCltvValue + } + return 0 +} + +func (x *Onion) GetSharedSecret() string { + if x != nil { + return x.SharedSecret + } + return "" +} + +func (x *Onion) GetNextOnion() string { + if x != nil { + return x.NextOnion + } + return "" +} + +type Htlc struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ShortChannelId string `protobuf:"bytes,1,opt,name=short_channel_id,json=shortChannelId,proto3" json:"short_channel_id,omitempty"` + Id uint64 `protobuf:"varint,2,opt,name=id,proto3" json:"id,omitempty"` + AmountMsat uint64 `protobuf:"varint,3,opt,name=amount_msat,json=amountMsat,proto3" json:"amount_msat,omitempty"` + CltvExpiry uint32 `protobuf:"varint,4,opt,name=cltv_expiry,json=cltvExpiry,proto3" json:"cltv_expiry,omitempty"` + CltvExpiryRelative uint32 `protobuf:"varint,5,opt,name=cltv_expiry_relative,json=cltvExpiryRelative,proto3" json:"cltv_expiry_relative,omitempty"` + PaymentHash string `protobuf:"bytes,6,opt,name=payment_hash,json=paymentHash,proto3" json:"payment_hash,omitempty"` +} + +func (x *Htlc) Reset() { + *x = Htlc{} + if protoimpl.UnsafeEnabled { + mi := &file_cln_plugin_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Htlc) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Htlc) ProtoMessage() {} + +func (x *Htlc) ProtoReflect() protoreflect.Message { + mi := &file_cln_plugin_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Htlc.ProtoReflect.Descriptor instead. +func (*Htlc) Descriptor() ([]byte, []int) { + return file_cln_plugin_proto_rawDescGZIP(), []int{2} +} + +func (x *Htlc) GetShortChannelId() string { + if x != nil { + return x.ShortChannelId + } + return "" +} + +func (x *Htlc) GetId() uint64 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *Htlc) GetAmountMsat() uint64 { + if x != nil { + return x.AmountMsat + } + return 0 +} + +func (x *Htlc) GetCltvExpiry() uint32 { + if x != nil { + return x.CltvExpiry + } + return 0 +} + +func (x *Htlc) GetCltvExpiryRelative() uint32 { + if x != nil { + return x.CltvExpiryRelative + } + return 0 +} + +func (x *Htlc) GetPaymentHash() string { + if x != nil { + return x.PaymentHash + } + return "" +} + +type HtlcResolution struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Correlationid string `protobuf:"bytes,1,opt,name=correlationid,proto3" json:"correlationid,omitempty"` + // Types that are assignable to Outcome: + // *HtlcResolution_Fail + // *HtlcResolution_Continue + // *HtlcResolution_Resolve + Outcome isHtlcResolution_Outcome `protobuf_oneof:"outcome"` +} + +func (x *HtlcResolution) Reset() { + *x = HtlcResolution{} + if protoimpl.UnsafeEnabled { + mi := &file_cln_plugin_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *HtlcResolution) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HtlcResolution) ProtoMessage() {} + +func (x *HtlcResolution) ProtoReflect() protoreflect.Message { + mi := &file_cln_plugin_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HtlcResolution.ProtoReflect.Descriptor instead. +func (*HtlcResolution) Descriptor() ([]byte, []int) { + return file_cln_plugin_proto_rawDescGZIP(), []int{3} +} + +func (x *HtlcResolution) GetCorrelationid() string { + if x != nil { + return x.Correlationid + } + return "" +} + +func (m *HtlcResolution) GetOutcome() isHtlcResolution_Outcome { + if m != nil { + return m.Outcome + } + return nil +} + +func (x *HtlcResolution) GetFail() *HtlcFail { + if x, ok := x.GetOutcome().(*HtlcResolution_Fail); ok { + return x.Fail + } + return nil +} + +func (x *HtlcResolution) GetContinue() *HtlcContinue { + if x, ok := x.GetOutcome().(*HtlcResolution_Continue); ok { + return x.Continue + } + return nil +} + +func (x *HtlcResolution) GetResolve() *HtlcResolve { + if x, ok := x.GetOutcome().(*HtlcResolution_Resolve); ok { + return x.Resolve + } + return nil +} + +type isHtlcResolution_Outcome interface { + isHtlcResolution_Outcome() +} + +type HtlcResolution_Fail struct { + Fail *HtlcFail `protobuf:"bytes,2,opt,name=fail,proto3,oneof"` +} + +type HtlcResolution_Continue struct { + Continue *HtlcContinue `protobuf:"bytes,3,opt,name=continue,proto3,oneof"` +} + +type HtlcResolution_Resolve struct { + Resolve *HtlcResolve `protobuf:"bytes,4,opt,name=resolve,proto3,oneof"` +} + +func (*HtlcResolution_Fail) isHtlcResolution_Outcome() {} + +func (*HtlcResolution_Continue) isHtlcResolution_Outcome() {} + +func (*HtlcResolution_Resolve) isHtlcResolution_Outcome() {} + +type HtlcContinue struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Payload *string `protobuf:"bytes,1,opt,name=payload,proto3,oneof" json:"payload,omitempty"` + ForwardTo *string `protobuf:"bytes,2,opt,name=forward_to,json=forwardTo,proto3,oneof" json:"forward_to,omitempty"` +} + +func (x *HtlcContinue) Reset() { + *x = HtlcContinue{} + if protoimpl.UnsafeEnabled { + mi := &file_cln_plugin_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *HtlcContinue) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HtlcContinue) ProtoMessage() {} + +func (x *HtlcContinue) ProtoReflect() protoreflect.Message { + mi := &file_cln_plugin_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HtlcContinue.ProtoReflect.Descriptor instead. +func (*HtlcContinue) Descriptor() ([]byte, []int) { + return file_cln_plugin_proto_rawDescGZIP(), []int{4} +} + +func (x *HtlcContinue) GetPayload() string { + if x != nil && x.Payload != nil { + return *x.Payload + } + return "" +} + +func (x *HtlcContinue) GetForwardTo() string { + if x != nil && x.ForwardTo != nil { + return *x.ForwardTo + } + return "" +} + +type HtlcFail struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Types that are assignable to Failure: + // *HtlcFail_FailureMessage + // *HtlcFail_FailureOnion + Failure isHtlcFail_Failure `protobuf_oneof:"failure"` +} + +func (x *HtlcFail) Reset() { + *x = HtlcFail{} + if protoimpl.UnsafeEnabled { + mi := &file_cln_plugin_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *HtlcFail) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HtlcFail) ProtoMessage() {} + +func (x *HtlcFail) ProtoReflect() protoreflect.Message { + mi := &file_cln_plugin_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HtlcFail.ProtoReflect.Descriptor instead. +func (*HtlcFail) Descriptor() ([]byte, []int) { + return file_cln_plugin_proto_rawDescGZIP(), []int{5} +} + +func (m *HtlcFail) GetFailure() isHtlcFail_Failure { + if m != nil { + return m.Failure + } + return nil +} + +func (x *HtlcFail) GetFailureMessage() string { + if x, ok := x.GetFailure().(*HtlcFail_FailureMessage); ok { + return x.FailureMessage + } + return "" +} + +func (x *HtlcFail) GetFailureOnion() string { + if x, ok := x.GetFailure().(*HtlcFail_FailureOnion); ok { + return x.FailureOnion + } + return "" +} + +type isHtlcFail_Failure interface { + isHtlcFail_Failure() +} + +type HtlcFail_FailureMessage struct { + FailureMessage string `protobuf:"bytes,1,opt,name=failure_message,json=failureMessage,proto3,oneof"` +} + +type HtlcFail_FailureOnion struct { + FailureOnion string `protobuf:"bytes,2,opt,name=failure_onion,json=failureOnion,proto3,oneof"` +} + +func (*HtlcFail_FailureMessage) isHtlcFail_Failure() {} + +func (*HtlcFail_FailureOnion) isHtlcFail_Failure() {} + +type HtlcResolve struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + PaymentKey string `protobuf:"bytes,1,opt,name=payment_key,json=paymentKey,proto3" json:"payment_key,omitempty"` +} + +func (x *HtlcResolve) Reset() { + *x = HtlcResolve{} + if protoimpl.UnsafeEnabled { + mi := &file_cln_plugin_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *HtlcResolve) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HtlcResolve) ProtoMessage() {} + +func (x *HtlcResolve) ProtoReflect() protoreflect.Message { + mi := &file_cln_plugin_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HtlcResolve.ProtoReflect.Descriptor instead. +func (*HtlcResolve) Descriptor() ([]byte, []int) { + return file_cln_plugin_proto_rawDescGZIP(), []int{6} +} + +func (x *HtlcResolve) GetPaymentKey() string { + if x != nil { + return x.PaymentKey + } + return "" +} + +var File_cln_plugin_proto protoreflect.FileDescriptor + +var file_cln_plugin_proto_rawDesc = []byte{ + 0x0a, 0x10, 0x63, 0x6c, 0x6e, 0x5f, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x22, 0x8c, 0x01, 0x0a, 0x0c, 0x48, 0x74, 0x6c, 0x63, 0x41, 0x63, 0x63, 0x65, 0x70, + 0x74, 0x65, 0x64, 0x12, 0x24, 0x0a, 0x0d, 0x63, 0x6f, 0x72, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x63, 0x6f, 0x72, 0x72, + 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x69, 0x64, 0x12, 0x1c, 0x0a, 0x05, 0x6f, 0x6e, 0x69, + 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x06, 0x2e, 0x4f, 0x6e, 0x69, 0x6f, 0x6e, + 0x52, 0x05, 0x6f, 0x6e, 0x69, 0x6f, 0x6e, 0x12, 0x19, 0x0a, 0x04, 0x68, 0x74, 0x6c, 0x63, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x05, 0x2e, 0x48, 0x74, 0x6c, 0x63, 0x52, 0x04, 0x68, 0x74, + 0x6c, 0x63, 0x12, 0x1d, 0x0a, 0x0a, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x5f, 0x74, 0x6f, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x54, + 0x6f, 0x22, 0xe2, 0x01, 0x0a, 0x05, 0x4f, 0x6e, 0x69, 0x6f, 0x6e, 0x12, 0x18, 0x0a, 0x07, 0x70, + 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x70, 0x61, + 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x28, 0x0a, 0x10, 0x73, 0x68, 0x6f, 0x72, 0x74, 0x5f, 0x63, + 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0e, 0x73, 0x68, 0x6f, 0x72, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x49, 0x64, 0x12, + 0x21, 0x0a, 0x0c, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x5f, 0x6d, 0x73, 0x61, 0x74, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0b, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x4d, 0x73, + 0x61, 0x74, 0x12, 0x2e, 0x0a, 0x13, 0x6f, 0x75, 0x74, 0x67, 0x6f, 0x69, 0x6e, 0x67, 0x5f, 0x63, + 0x6c, 0x74, 0x76, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0d, 0x52, + 0x11, 0x6f, 0x75, 0x74, 0x67, 0x6f, 0x69, 0x6e, 0x67, 0x43, 0x6c, 0x74, 0x76, 0x56, 0x61, 0x6c, + 0x75, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x68, 0x61, 0x72, 0x65, 0x64, 0x5f, 0x73, 0x65, 0x63, + 0x72, 0x65, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x68, 0x61, 0x72, 0x65, + 0x64, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x6e, 0x65, 0x78, 0x74, 0x5f, + 0x6f, 0x6e, 0x69, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x65, 0x78, + 0x74, 0x4f, 0x6e, 0x69, 0x6f, 0x6e, 0x22, 0xd7, 0x01, 0x0a, 0x04, 0x48, 0x74, 0x6c, 0x63, 0x12, + 0x28, 0x0a, 0x10, 0x73, 0x68, 0x6f, 0x72, 0x74, 0x5f, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, + 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x73, 0x68, 0x6f, 0x72, 0x74, + 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x49, 0x64, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x61, 0x6d, 0x6f, + 0x75, 0x6e, 0x74, 0x5f, 0x6d, 0x73, 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0a, + 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x4d, 0x73, 0x61, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x63, 0x6c, + 0x74, 0x76, 0x5f, 0x65, 0x78, 0x70, 0x69, 0x72, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0d, 0x52, + 0x0a, 0x63, 0x6c, 0x74, 0x76, 0x45, 0x78, 0x70, 0x69, 0x72, 0x79, 0x12, 0x30, 0x0a, 0x14, 0x63, + 0x6c, 0x74, 0x76, 0x5f, 0x65, 0x78, 0x70, 0x69, 0x72, 0x79, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x74, + 0x69, 0x76, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x12, 0x63, 0x6c, 0x74, 0x76, 0x45, + 0x78, 0x70, 0x69, 0x72, 0x79, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x76, 0x65, 0x12, 0x21, 0x0a, + 0x0c, 0x70, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x06, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0b, 0x70, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x48, 0x61, 0x73, 0x68, + 0x22, 0xb9, 0x01, 0x0a, 0x0e, 0x48, 0x74, 0x6c, 0x63, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x75, 0x74, + 0x69, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0d, 0x63, 0x6f, 0x72, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x63, 0x6f, 0x72, 0x72, + 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x69, 0x64, 0x12, 0x1f, 0x0a, 0x04, 0x66, 0x61, 0x69, + 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x09, 0x2e, 0x48, 0x74, 0x6c, 0x63, 0x46, 0x61, + 0x69, 0x6c, 0x48, 0x00, 0x52, 0x04, 0x66, 0x61, 0x69, 0x6c, 0x12, 0x2b, 0x0a, 0x08, 0x63, 0x6f, + 0x6e, 0x74, 0x69, 0x6e, 0x75, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x48, + 0x74, 0x6c, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x69, 0x6e, 0x75, 0x65, 0x48, 0x00, 0x52, 0x08, 0x63, + 0x6f, 0x6e, 0x74, 0x69, 0x6e, 0x75, 0x65, 0x12, 0x28, 0x0a, 0x07, 0x72, 0x65, 0x73, 0x6f, 0x6c, + 0x76, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x48, 0x74, 0x6c, 0x63, 0x52, + 0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x48, 0x00, 0x52, 0x07, 0x72, 0x65, 0x73, 0x6f, 0x6c, 0x76, + 0x65, 0x42, 0x09, 0x0a, 0x07, 0x6f, 0x75, 0x74, 0x63, 0x6f, 0x6d, 0x65, 0x22, 0x6c, 0x0a, 0x0c, + 0x48, 0x74, 0x6c, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x69, 0x6e, 0x75, 0x65, 0x12, 0x1d, 0x0a, 0x07, + 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, + 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x88, 0x01, 0x01, 0x12, 0x22, 0x0a, 0x0a, 0x66, + 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x5f, 0x74, 0x6f, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, + 0x01, 0x52, 0x09, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x54, 0x6f, 0x88, 0x01, 0x01, 0x42, + 0x0a, 0x0a, 0x08, 0x5f, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x42, 0x0d, 0x0a, 0x0b, 0x5f, + 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x5f, 0x74, 0x6f, 0x22, 0x67, 0x0a, 0x08, 0x48, 0x74, + 0x6c, 0x63, 0x46, 0x61, 0x69, 0x6c, 0x12, 0x29, 0x0a, 0x0f, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, + 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48, + 0x00, 0x52, 0x0e, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x12, 0x25, 0x0a, 0x0d, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x5f, 0x6f, 0x6e, 0x69, + 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0c, 0x66, 0x61, 0x69, 0x6c, + 0x75, 0x72, 0x65, 0x4f, 0x6e, 0x69, 0x6f, 0x6e, 0x42, 0x09, 0x0a, 0x07, 0x66, 0x61, 0x69, 0x6c, + 0x75, 0x72, 0x65, 0x22, 0x2e, 0x0a, 0x0b, 0x48, 0x74, 0x6c, 0x63, 0x52, 0x65, 0x73, 0x6f, 0x6c, + 0x76, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x70, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x6b, 0x65, + 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x70, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, + 0x4b, 0x65, 0x79, 0x32, 0x3d, 0x0a, 0x09, 0x43, 0x6c, 0x6e, 0x50, 0x6c, 0x75, 0x67, 0x69, 0x6e, + 0x12, 0x30, 0x0a, 0x0a, 0x48, 0x74, 0x6c, 0x63, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x0f, + 0x2e, 0x48, 0x74, 0x6c, 0x63, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x1a, + 0x0d, 0x2e, 0x48, 0x74, 0x6c, 0x63, 0x41, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x28, 0x01, + 0x30, 0x01, 0x42, 0x28, 0x5a, 0x26, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, + 0x2f, 0x62, 0x72, 0x65, 0x65, 0x7a, 0x2f, 0x6c, 0x73, 0x70, 0x64, 0x2f, 0x63, 0x6c, 0x6e, 0x5f, + 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_cln_plugin_proto_rawDescOnce sync.Once + file_cln_plugin_proto_rawDescData = file_cln_plugin_proto_rawDesc +) + +func file_cln_plugin_proto_rawDescGZIP() []byte { + file_cln_plugin_proto_rawDescOnce.Do(func() { + file_cln_plugin_proto_rawDescData = protoimpl.X.CompressGZIP(file_cln_plugin_proto_rawDescData) + }) + return file_cln_plugin_proto_rawDescData +} + +var file_cln_plugin_proto_msgTypes = make([]protoimpl.MessageInfo, 7) +var file_cln_plugin_proto_goTypes = []interface{}{ + (*HtlcAccepted)(nil), // 0: HtlcAccepted + (*Onion)(nil), // 1: Onion + (*Htlc)(nil), // 2: Htlc + (*HtlcResolution)(nil), // 3: HtlcResolution + (*HtlcContinue)(nil), // 4: HtlcContinue + (*HtlcFail)(nil), // 5: HtlcFail + (*HtlcResolve)(nil), // 6: HtlcResolve +} +var file_cln_plugin_proto_depIdxs = []int32{ + 1, // 0: HtlcAccepted.onion:type_name -> Onion + 2, // 1: HtlcAccepted.htlc:type_name -> Htlc + 5, // 2: HtlcResolution.fail:type_name -> HtlcFail + 4, // 3: HtlcResolution.continue:type_name -> HtlcContinue + 6, // 4: HtlcResolution.resolve:type_name -> HtlcResolve + 3, // 5: ClnPlugin.HtlcStream:input_type -> HtlcResolution + 0, // 6: ClnPlugin.HtlcStream:output_type -> HtlcAccepted + 6, // [6:7] is the sub-list for method output_type + 5, // [5:6] is the sub-list for method input_type + 5, // [5:5] is the sub-list for extension type_name + 5, // [5:5] is the sub-list for extension extendee + 0, // [0:5] is the sub-list for field type_name +} + +func init() { file_cln_plugin_proto_init() } +func file_cln_plugin_proto_init() { + if File_cln_plugin_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_cln_plugin_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*HtlcAccepted); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_cln_plugin_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Onion); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_cln_plugin_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Htlc); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_cln_plugin_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*HtlcResolution); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_cln_plugin_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*HtlcContinue); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_cln_plugin_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*HtlcFail); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_cln_plugin_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*HtlcResolve); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + file_cln_plugin_proto_msgTypes[3].OneofWrappers = []interface{}{ + (*HtlcResolution_Fail)(nil), + (*HtlcResolution_Continue)(nil), + (*HtlcResolution_Resolve)(nil), + } + file_cln_plugin_proto_msgTypes[4].OneofWrappers = []interface{}{} + file_cln_plugin_proto_msgTypes[5].OneofWrappers = []interface{}{ + (*HtlcFail_FailureMessage)(nil), + (*HtlcFail_FailureOnion)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_cln_plugin_proto_rawDesc, + NumEnums: 0, + NumMessages: 7, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_cln_plugin_proto_goTypes, + DependencyIndexes: file_cln_plugin_proto_depIdxs, + MessageInfos: file_cln_plugin_proto_msgTypes, + }.Build() + File_cln_plugin_proto = out.File + file_cln_plugin_proto_rawDesc = nil + file_cln_plugin_proto_goTypes = nil + file_cln_plugin_proto_depIdxs = nil +} diff --git a/cln_plugin/proto/cln_plugin.proto b/cln_plugin/proto/cln_plugin.proto new file mode 100644 index 0000000..be35059 --- /dev/null +++ b/cln_plugin/proto/cln_plugin.proto @@ -0,0 +1,56 @@ +syntax = "proto3"; +option go_package="github.com/breez/lspd/cln_plugin/proto"; + +service ClnPlugin { + rpc HtlcStream(stream HtlcResolution) returns (stream HtlcAccepted); +} + +message HtlcAccepted { + string correlationid = 1; + Onion onion = 2; + Htlc htlc = 3; + string forward_to = 4; +} + +message Onion { + string payload = 1; + string short_channel_id = 2; + uint64 forward_msat = 3; + uint32 outgoing_cltv_value = 4; + string shared_secret = 5; + string next_onion = 6; +} + +message Htlc { + string short_channel_id = 1; + uint64 id = 2; + uint64 amount_msat = 3; + uint32 cltv_expiry = 4; + uint32 cltv_expiry_relative = 5; + string payment_hash = 6; +} + +message HtlcResolution { + string correlationid = 1; + oneof outcome { + HtlcFail fail = 2; + HtlcContinue continue = 3; + HtlcResolve resolve = 4; + } +} + +message HtlcContinue { + optional string payload = 1; + optional string forward_to = 2; +} + +message HtlcFail { + oneof failure { + string failure_message = 1; + string failure_onion = 2; + } +} + +message HtlcResolve { + string payment_key = 1; +} diff --git a/cln_plugin/proto/cln_plugin_grpc.pb.go b/cln_plugin/proto/cln_plugin_grpc.pb.go new file mode 100644 index 0000000..1f8ccd2 --- /dev/null +++ b/cln_plugin/proto/cln_plugin_grpc.pb.go @@ -0,0 +1,137 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.2.0 +// - protoc v3.21.12 +// source: cln_plugin.proto + +package proto + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.32.0 or later. +const _ = grpc.SupportPackageIsVersion7 + +// ClnPluginClient is the client API for ClnPlugin service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type ClnPluginClient interface { + HtlcStream(ctx context.Context, opts ...grpc.CallOption) (ClnPlugin_HtlcStreamClient, error) +} + +type clnPluginClient struct { + cc grpc.ClientConnInterface +} + +func NewClnPluginClient(cc grpc.ClientConnInterface) ClnPluginClient { + return &clnPluginClient{cc} +} + +func (c *clnPluginClient) HtlcStream(ctx context.Context, opts ...grpc.CallOption) (ClnPlugin_HtlcStreamClient, error) { + stream, err := c.cc.NewStream(ctx, &ClnPlugin_ServiceDesc.Streams[0], "/ClnPlugin/HtlcStream", opts...) + if err != nil { + return nil, err + } + x := &clnPluginHtlcStreamClient{stream} + return x, nil +} + +type ClnPlugin_HtlcStreamClient interface { + Send(*HtlcResolution) error + Recv() (*HtlcAccepted, error) + grpc.ClientStream +} + +type clnPluginHtlcStreamClient struct { + grpc.ClientStream +} + +func (x *clnPluginHtlcStreamClient) Send(m *HtlcResolution) error { + return x.ClientStream.SendMsg(m) +} + +func (x *clnPluginHtlcStreamClient) Recv() (*HtlcAccepted, error) { + m := new(HtlcAccepted) + if err := x.ClientStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} + +// ClnPluginServer is the server API for ClnPlugin service. +// All implementations must embed UnimplementedClnPluginServer +// for forward compatibility +type ClnPluginServer interface { + HtlcStream(ClnPlugin_HtlcStreamServer) error + mustEmbedUnimplementedClnPluginServer() +} + +// UnimplementedClnPluginServer must be embedded to have forward compatible implementations. +type UnimplementedClnPluginServer struct { +} + +func (UnimplementedClnPluginServer) HtlcStream(ClnPlugin_HtlcStreamServer) error { + return status.Errorf(codes.Unimplemented, "method HtlcStream not implemented") +} +func (UnimplementedClnPluginServer) mustEmbedUnimplementedClnPluginServer() {} + +// UnsafeClnPluginServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to ClnPluginServer will +// result in compilation errors. +type UnsafeClnPluginServer interface { + mustEmbedUnimplementedClnPluginServer() +} + +func RegisterClnPluginServer(s grpc.ServiceRegistrar, srv ClnPluginServer) { + s.RegisterService(&ClnPlugin_ServiceDesc, srv) +} + +func _ClnPlugin_HtlcStream_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(ClnPluginServer).HtlcStream(&clnPluginHtlcStreamServer{stream}) +} + +type ClnPlugin_HtlcStreamServer interface { + Send(*HtlcAccepted) error + Recv() (*HtlcResolution, error) + grpc.ServerStream +} + +type clnPluginHtlcStreamServer struct { + grpc.ServerStream +} + +func (x *clnPluginHtlcStreamServer) Send(m *HtlcAccepted) error { + return x.ServerStream.SendMsg(m) +} + +func (x *clnPluginHtlcStreamServer) Recv() (*HtlcResolution, error) { + m := new(HtlcResolution) + if err := x.ServerStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} + +// ClnPlugin_ServiceDesc is the grpc.ServiceDesc for ClnPlugin service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var ClnPlugin_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "ClnPlugin", + HandlerType: (*ClnPluginServer)(nil), + Methods: []grpc.MethodDesc{}, + Streams: []grpc.StreamDesc{ + { + StreamName: "HtlcStream", + Handler: _ClnPlugin_HtlcStream_Handler, + ServerStreams: true, + ClientStreams: true, + }, + }, + Metadata: "cln_plugin.proto", +} diff --git a/cln_plugin/server.go b/cln_plugin/server.go new file mode 100644 index 0000000..b03c97c --- /dev/null +++ b/cln_plugin/server.go @@ -0,0 +1,401 @@ +package cln_plugin + +import ( + "fmt" + "log" + "net" + "sync" + "time" + + "github.com/breez/lspd/cln_plugin/proto" + grpc "google.golang.org/grpc" + "google.golang.org/grpc/keepalive" +) + +// Internal htlc_accepted message meant for the sendQueue. +type htlcAcceptedMsg struct { + id string + htlc *HtlcAccepted + timeout time.Time +} + +// Internal htlc result message meant for the recvQueue. +type htlcResultMsg struct { + id string + result interface{} +} + +type server struct { + proto.ClnPluginServer + listenAddress string + subscriberTimeout time.Duration + grpcServer *grpc.Server + mtx sync.Mutex + stream proto.ClnPlugin_HtlcStreamServer + newSubscriber chan struct{} + started chan struct{} + done chan struct{} + completed chan struct{} + startError chan error + sendQueue chan *htlcAcceptedMsg + recvQueue chan *htlcResultMsg +} + +// Creates a new grpc server +func NewServer(listenAddress string, subscriberTimeout time.Duration) *server { + // TODO: Set a sane max queue size + return &server{ + listenAddress: listenAddress, + subscriberTimeout: subscriberTimeout, + // The send queue exists to buffer messages until a subscriber is active. + sendQueue: make(chan *htlcAcceptedMsg, 10000), + // The receive queue exists mainly to allow returning timeouts to the + // cln plugin. If there is no subscriber active within the subscriber + // timeout period these results can be put directly on the receive queue. + recvQueue: make(chan *htlcResultMsg, 10000), + started: make(chan struct{}), + startError: make(chan error, 1), + } +} + +// Starts the grpc server. Blocks until the servver is stopped. WaitStarted can +// be called to ensure the server is started without errors if this function +// is run as a goroutine. +func (s *server) Start() error { + s.mtx.Lock() + if s.grpcServer != nil { + s.mtx.Unlock() + return nil + } + + lis, err := net.Listen("tcp", s.listenAddress) + if err != nil { + log.Printf("ERROR Server failed to listen: %v", err) + s.startError <- err + s.mtx.Unlock() + return err + } + + s.done = make(chan struct{}) + s.completed = make(chan struct{}) + s.newSubscriber = make(chan struct{}) + s.grpcServer = grpc.NewServer( + grpc.KeepaliveParams(keepalive.ServerParameters{ + Time: time.Duration(1) * time.Second, + Timeout: time.Duration(10) * time.Second, + }), + grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{ + MinTime: time.Duration(1) * time.Second, + }), + ) + s.mtx.Unlock() + proto.RegisterClnPluginServer(s.grpcServer, s) + + log.Printf("Server starting to listen on %s.", s.listenAddress) + go s.listenHtlcRequests() + go s.listenHtlcResponses() + close(s.started) + err = s.grpcServer.Serve(lis) + close(s.completed) + return err +} + +// Waits until the server has started, or errored during startup. +func (s *server) WaitStarted() error { + select { + case <-s.started: + return nil + case err := <-s.startError: + return err + } +} + +// Stops all work from the grpc server immediately. +func (s *server) Stop() { + s.mtx.Lock() + defer s.mtx.Unlock() + log.Printf("Server Stop() called.") + if s.grpcServer == nil { + return + } + + s.grpcServer.Stop() + s.grpcServer = nil + + close(s.done) + <-s.completed + log.Printf("Server stopped.") +} + +// Grpc method that is called when a new client subscribes. There can only be +// one subscriber active at a time. If there is an error receiving or sending +// from or to the subscriber, the subscription is closed. +func (s *server) HtlcStream(stream proto.ClnPlugin_HtlcStreamServer) error { + s.mtx.Lock() + if s.stream == nil { + log.Printf("Got a new HTLC stream subscription request.") + } else { + s.mtx.Unlock() + log.Printf("Got a HTLC stream subscription request, but subscription " + + "was already active.") + return fmt.Errorf("already subscribed") + } + + s.stream = stream + + // Notify listeners that a new subscriber is active. Replace the chan with + // a new one immediately in case this subscriber is dropped later. + close(s.newSubscriber) + s.newSubscriber = make(chan struct{}) + s.mtx.Unlock() + + <-stream.Context().Done() + log.Printf("HtlcStream context is done. Return: %v", stream.Context().Err()) + + // Remove the subscriber. + s.mtx.Lock() + s.stream = nil + s.mtx.Unlock() + + return stream.Context().Err() +} + +// Enqueues a htlc_accepted message for send to the grpc client. +func (s *server) Send(id string, h *HtlcAccepted) { + s.sendQueue <- &htlcAcceptedMsg{ + id: id, + htlc: h, + timeout: time.Now().Add(s.subscriberTimeout), + } +} + +// Receives the next htlc resolution message from the grpc client. Returns id +// and message. Blocks until a message is available. Returns a nil message if +// the server is done. This function effectively waits until a subscriber is +// active and has sent a message. +func (s *server) Receive() (string, interface{}) { + select { + case <-s.done: + return "", nil + case msg := <-s.recvQueue: + return msg.id, msg.result + } +} + +// Listens to sendQueue for htlc_accepted requests from cln. The message will be +// held until a subscriber is active, or the subscriber timeout expires. The +// messages are sent to the grpc client in fifo order. +func (s *server) listenHtlcRequests() { + for { + select { + case <-s.done: + log.Printf("listenHtlcRequests received done. Stop listening.") + return + case msg := <-s.sendQueue: + s.handleHtlcAccepted(msg) + } + } +} + +// Attempts to send a htlc_accepted message to the grpc client. The message will +// be held until a subscriber is active, or the subscriber timeout expires. +func (s *server) handleHtlcAccepted(msg *htlcAcceptedMsg) { + for { + s.mtx.Lock() + stream := s.stream + ns := s.newSubscriber + s.mtx.Unlock() + + // If there is no active subscription, wait until there is a new + // subscriber, or the message times out. + if stream == nil { + select { + case <-s.done: + log.Printf("handleHtlcAccepted received server done. Stop processing.") + return + case <-ns: + log.Printf("got a new subscriber. continue handleHtlcAccepted.") + continue + case <-time.After(time.Until(msg.timeout)): + log.Printf( + "WARNING: htlc with id '%s' timed out after '%v' waiting "+ + "for grpc subscriber: %+v", + msg.id, + s.subscriberTimeout, + msg.htlc, + ) + + // If the subscriber timeout expires while holding the htlc + // we short circuit the htlc by sending the default result + // (continue) to cln. + s.recvQueue <- &htlcResultMsg{ + id: msg.id, + result: s.defaultResult(), + } + + return + } + } + + // There is a subscriber. Attempt to send the htlc_accepted message. + err := stream.Send(&proto.HtlcAccepted{ + Correlationid: msg.id, + Onion: &proto.Onion{ + Payload: msg.htlc.Onion.Payload, + ShortChannelId: msg.htlc.Onion.ShortChannelId, + ForwardMsat: msg.htlc.Onion.ForwardMsat, + OutgoingCltvValue: msg.htlc.Onion.OutgoingCltvValue, + SharedSecret: msg.htlc.Onion.SharedSecret, + NextOnion: msg.htlc.Onion.NextOnion, + }, + Htlc: &proto.Htlc{ + ShortChannelId: msg.htlc.Htlc.ShortChannelId, + Id: msg.htlc.Htlc.Id, + AmountMsat: msg.htlc.Htlc.AmountMsat, + CltvExpiry: msg.htlc.Htlc.CltvExpiry, + CltvExpiryRelative: msg.htlc.Htlc.CltvExpiryRelative, + PaymentHash: msg.htlc.Htlc.PaymentHash, + }, + ForwardTo: msg.htlc.ForwardTo, + }) + + // If there is no error, we're done. + if err == nil { + return + } + + // If we end up here, there was an error sending the message to the + // grpc client. + // TODO: If the Send errors, but the context is not done, this will + // currently retry immediately. Check whether the context is really + // done on an error! + log.Printf("Error sending htlc_accepted message to subscriber. Retrying: %v", err) + } +} + +// Listens to htlc responses from the grpc client and appends them to the +// receive queue. The messages from the receive queue are read in the Receive +// function. +func (s *server) listenHtlcResponses() { + for { + select { + case <-s.done: + log.Printf("listenHtlcResponses received done. Stopping listening.") + return + default: + resp := s.recv() + s.recvQueue <- &htlcResultMsg{ + id: resp.Correlationid, + result: s.mapResult(resp.Outcome), + } + } + } +} + +// Helper function that blocks until a message from a grpc client is received +// or the server stops. Either returns a received message, or nil if the server +// has stopped. +func (s *server) recv() *proto.HtlcResolution { + for { + // make a copy of the used fields, to make sure state updates don't + // surprise us. The newSubscriber chan is swapped whenever a new + // subscriber arrives. + s.mtx.Lock() + stream := s.stream + ns := s.newSubscriber + s.mtx.Unlock() + + if stream == nil { + log.Printf("Got no subscribers for receive. Waiting for subscriber.") + select { + case <-s.done: + log.Printf("Done signalled, stopping receive.") + return nil + case <-ns: + log.Printf("New subscription available for receive, continue receive.") + continue + } + } + + // There is a subscription active. Attempt to receive a message. + r, err := stream.Recv() + if err == nil { + log.Printf("Received HtlcResolution %+v", r) + return r + } + + // Receiving the message failed, so the subscription is broken. Remove + // it if it hasn't been updated already. We'll try receiving again in + // the next iteration of the for loop. + // TODO: If the Recv errors, but the context is not done, this will + // currently retry immediately. Check whether the context is really + // done on an error! + log.Printf("Recv() errored, Retrying: %v", err) + } +} + +// Maps a grpc result to the corresponding result for cln. The cln message +// is a raw json message, so it's easiest to use a map directly. +func (s *server) mapResult(outcome interface{}) interface{} { + // result: continue + cont, ok := outcome.(*proto.HtlcResolution_Continue) + if ok { + result := map[string]interface{}{ + "result": "continue", + } + + if cont.Continue.ForwardTo != nil { + result["forward_to"] = *cont.Continue.ForwardTo + } + + if cont.Continue.Payload != nil { + result["payload"] = *cont.Continue.Payload + } + + return result + } + + // result: fail + fail, ok := outcome.(*proto.HtlcResolution_Fail) + if ok { + result := map[string]interface{}{ + "result": "fail", + } + + fm, ok := fail.Fail.Failure.(*proto.HtlcFail_FailureMessage) + if ok { + result["failure_message"] = fm.FailureMessage + } + + fo, ok := fail.Fail.Failure.(*proto.HtlcFail_FailureOnion) + if ok { + result["failure_onion"] = fo.FailureOnion + } + + return result + } + + // result: resolve + resolve, ok := outcome.(*proto.HtlcResolution_Resolve) + if ok { + result := map[string]interface{}{ + "result": "resolve", + "payment_key": resolve.Resolve.PaymentKey, + } + + return result + } + + // On an unknown result we haven't implemented all possible cases from the + // grpc message. We don't understand what's going on, so we'll return + // result: continue. + log.Printf("Unexpected htlc resolution type %T: %+v", outcome, outcome) + return s.defaultResult() +} + +// Returns a result: continue message. +func (s *server) defaultResult() interface{} { + return map[string]interface{}{ + "result": "continue", + } +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..dd287e5 --- /dev/null +++ b/config/config.go @@ -0,0 +1,98 @@ +package config + +type NodeConfig struct { + // Name of the LSP. If empty, the node's alias will be taken instead. + Name string `json:name,omitempty` + + // The public key of the lightning node. + NodePubkey string `json:nodePubkey,omitempty` + + // Hex encoded private key of the LSP. This is used to decrypt traffic from + // clients. + LspdPrivateKey string `json:"lspdPrivateKey"` + + // Tokens used to authenticate to lspd. These tokens must be unique for each + // configured node, so it's obvious which node an rpc call is meant for. + Tokens []string `json:"tokens"` + + // The network location of the lightning node, e.g. `12.34.56.78:9012` or + // `localhost:10011` + Host string `json:"host"` + + // Public channel amount is a reserved amount for public channels. If a + // zero conf channel is opened, it will never have this exact amount. + PublicChannelAmount int64 `json:"publicChannelAmount,string"` + + // The capacity of opened channels through the OpenChannel rpc. + ChannelAmount uint64 `json:"channelAmount,string"` + + // Value indicating whether channels opened through the OpenChannel rpc + // should be private. + ChannelPrivate bool `json:"channelPrivate"` + + // Number of blocks after which an opened channel is considered confirmed. + TargetConf uint32 `json:"targetConf,string"` + + // Minimum number of confirmations inputs for zero conf channel opens should + // have. + MinConfs *uint32 `json:"minConfs,string"` + + // Smallest htlc amount routed over channels opened with the OpenChannel + // rpc call. + MinHtlcMsat uint64 `json:"minHtlcMsat,string"` + + // The base fee for routing payments over the channel. It is configured on + // the node itself, but this value is returned in the ChannelInformation rpc. + BaseFeeMsat uint64 `json:"baseFeeMsat,string"` + + // The fee rate for routing payments over the channel. It is configured on + // the node itself, but this value is returned in the ChannelInformation rpc. + FeeRate float64 `json:"feeRate,string"` + + // Minimum timelock delta required for opening a zero conf channel. + TimeLockDelta uint32 `json:"timeLockDelta,string"` + + // Fee for opening a zero conf channel in satoshi per 10000 satoshi based + // on the incoming payment amount. + ChannelFeePermyriad int64 `json:"channelFeePermyriad,string"` + + // Minimum fee for opening a zero conf channel in millisatoshi. + ChannelMinimumFeeMsat int64 `json:"channelMinimumFeeMsat,string"` + + // Channel capacity that is added on top of the incoming payment amount + // when a new zero conf channel is opened. In satoshi. + AdditionalChannelCapacity int64 `json:"additionalChannelCapacity,string"` + + // The channel can be closed if not used this duration in seconds. + MaxInactiveDuration uint64 `json:"maxInactiveDuration,string"` + + // The maximum time to hold a htlc after sending a notification when the + // peer is offline. + NotificationTimeout string `json:"notificationTimeout,string"` + + // Set this field to connect to an LND node. + Lnd *LndConfig `json:"lnd,omitempty"` + + // Set this field to connect to a CLN node. + Cln *ClnConfig `json:"cln,omitempty"` +} + +type LndConfig struct { + // Address to the grpc api. + Address string `json:"address"` + + // tls cert for the grpc api. + Cert string `json:"cert"` + + // macaroon to use. + Macaroon string `json:"macaroon"` +} + +type ClnConfig struct { + // The address to the cln htlc acceptor grpc api shipped with lspd. + PluginAddress string `json:"pluginAddress"` + + // File path to the cln lightning-roc socket file. Find the path in + // cln-dir/mainnet/lightning-rpc + SocketPath string `json:"socketPath"` +} diff --git a/deploy/deploy.yml b/deploy/deploy.yml new file mode 100644 index 0000000..89e88a1 --- /dev/null +++ b/deploy/deploy.yml @@ -0,0 +1,333 @@ +AWSTemplateFormatVersion: '2010-09-09' + +Parameters: + KeyName: + Description: Name of an existing EC2 KeyPair to enable SSH access + Type: 'AWS::EC2::KeyPair::KeyName' + + LSPName: + Description: LSP Name + Type: String + + VPCID: + Description: The ID of the VPC in which to create the resources + Type: 'AWS::EC2::VPC::Id' + + LatestAmiId: + Type: 'AWS::SSM::Parameter::Value' + Default: '/aws/service/canonical/ubuntu/server/jammy/stable/current/amd64/hvm/ebs-gp2/ami-id' + +Resources: + # EC2 Instance + EC2Instance: + Type: 'AWS::EC2::Instance' + Properties: + InstanceType: m6a.xlarge + ImageId: !Ref LatestAmiId + KeyName: !Ref KeyName + BlockDeviceMappings: + - DeviceName: "/dev/sda1" + Ebs: + VolumeSize: 1024 + VolumeType: gp2 + DeleteOnTermination: true + UserData: + Fn::Base64: + !Sub | + #!/bin/bash + + # Elevate privileges + if [ "$EUID" -ne 0 ]; then + sudo bash "$0" "$@" + exit + fi + # Redirect all outputs to a log file + exec > >(tee -a "/tmp/deployment.log") 2>&1 + # fix locale if on debian + if grep -q "Debian" /etc/os-release; then + sed -i '/^# en_US.UTF-8 UTF-8/s/^# //' /etc/locale.gen + locale-gen + echo "export LC_ALL=en_US.UTF-8" >> /etc/bash.bashrc + echo "export LANG=en_US.UTF-8" >> /etc/bash.bashrc + + fi + source /etc/bash.bashrc + # create users + sudo adduser --disabled-password --gecos "" lightning + sudo adduser --disabled-password --gecos "" bitcoin + sudo adduser --disabled-password --gecos "" lspd + + # Create a file to store the credentials + CREDENTIALS="/home/lspd/credentials.txt" + touch "$CREDENTIALS" + # Generate a random password for PostgreSQL users + LSPD_DB_PASSWORD=$(> "$CREDENTIALS" + echo "postgres lspd:" >> "$CREDENTIALS" + echo "username: lspd " >> "$CREDENTIALS" + echo "password: $LSPD_DB_PASSWORD" >> "$CREDENTIALS" + echo "postgres lightning:" >> "$CREDENTIALS" + echo "username: lightning" >> "$CREDENTIALS" + echo "password: $LIGHTNING_DB_PASSWORD" >> "$CREDENTIALS" + + # Generic name if no name is provided (running locally) + if [ -z "$LSPName" ]; then + LSPName="lsp-$(> "$CREDENTIALS" + echo "rpcuser: lnd" >> "$CREDENTIALS" + echo "rpcpassword: $RPCPASSWORD" >> "$CREDENTIALS" + sudo mkdir /etc/bitcoin/ + sudo touch /etc/bitcoin/bitcoin.conf + cat <> "$CREDENTIALS" + sudo echo "cln hsm_secret backup:" >> "$CREDENTIALS" + sudo xxd /home/lightning/.lightning/bitcoin/hsm_secret >> "$CREDENTIALS" + + # Post install + PUBKEY=$(sudo -u lightning lightning-cli getinfo | jq .id | cut -d "\"" -f 2) + + LSPD_PRIVATE_KEY=$(lspd genkey | awk -F= '{print $2}' | cut -d "\"" -f 2) + TOKEN=$(lspd genkey | awk -F= '{print $2}' | cut -d "\"" -f 2) + EXTERNAL_IP=$(curl -s http://whatismyip.akamai.com/) + echo "### LSPD Credentials ###" >> "$CREDENTIALS" + echo "token: $TOKEN" >> "$CREDENTIALS" + echo "lspd_private_key: $LSPD_PRIVATE_KEY" >> "$CREDENTIALS" + + cat < >(tee -a "/tmp/deployment.log") 2>&1 +# fix locale if on debian +if grep -q "Debian" /etc/os-release; then + sed -i '/^# en_US.UTF-8 UTF-8/s/^# //' /etc/locale.gen + locale-gen + echo "export LC_ALL=en_US.UTF-8" >> /etc/bash.bashrc + echo "export LANG=en_US.UTF-8" >> /etc/bash.bashrc + +fi +source /etc/bash.bashrc +# create users +sudo adduser --disabled-password --gecos "" lightning +sudo adduser --disabled-password --gecos "" bitcoin +sudo adduser --disabled-password --gecos "" lspd + +# Create a file to store the credentials +CREDENTIALS="/home/lspd/credentials.txt" +touch "$CREDENTIALS" +# Generate a random password for PostgreSQL users +LSPD_DB_PASSWORD=$(> "$CREDENTIALS" +echo "postgres lspd:" >> "$CREDENTIALS" +echo "username: lspd " >> "$CREDENTIALS" +echo "password: $LSPD_DB_PASSWORD" >> "$CREDENTIALS" +echo "postgres lightning:" >> "$CREDENTIALS" +echo "username: lightning" >> "$CREDENTIALS" +echo "password: $LIGHTNING_DB_PASSWORD" >> "$CREDENTIALS" + +# Generic name if no name is provided (running locally) +if [ -z "$LSPName" ]; then +LSPName="lsp-$(> "$CREDENTIALS" +echo "rpcuser: lnd" >> "$CREDENTIALS" +echo "rpcpassword: $RPCPASSWORD" >> "$CREDENTIALS" +sudo mkdir /etc/bitcoin/ +sudo touch /etc/bitcoin/bitcoin.conf +cat <> "$CREDENTIALS" +sudo echo "cln hsm_secret backup:" >> "$CREDENTIALS" +sudo xxd /home/lightning/.lightning/bitcoin/hsm_secret >> "$CREDENTIALS" + +# Post install +PUBKEY=$(sudo -u lightning lightning-cli getinfo | jq .id | cut -d "\"" -f 2) + +LSPD_PRIVATE_KEY=$(lspd genkey | awk -F= '{print $2}' | cut -d "\"" -f 2) +TOKEN=$(lspd genkey | awk -F= '{print $2}' | cut -d "\"" -f 2) +EXTERNAL_IP=$(curl -s http://whatismyip.akamai.com/) +echo "### LSPD Credentials ###" >> "$CREDENTIALS" +echo "token: $TOKEN" >> "$CREDENTIALS" +echo "lspd_private_key: $LSPD_PRIVATE_KEY" >> "$CREDENTIALS" + +cat <; +ALTER ROLE WITH NOSUPERUSER INHERIT NOCREATEROLE NOCREATEDB LOGIN NOREPLICATION NOBYPASSRLS PASSWORD ''; +CREATE DATABASE WITH TEMPLATE = template0 ENCODING = 'UTF8' LC_COLLATE = 'en_US.UTF-8' LC_CTYPE = 'en_US.UTF-8'; +ALTER DATABASE OWNER TO ; +``` +##### RDS on AWS +``` +CREATE ROLE ; +ALTER ROLE WITH INHERIT NOCREATEROLE NOCREATEDB LOGIN NOBYPASSRLS PASSWORD ''; +CREATE DATABASE WITH TEMPLATE = template0 ENCODING = 'UTF8' LC_COLLATE = 'en_US.UTF-8' LC_CTYPE = 'en_US.UTF-8'; +ALTER DATABASE OWNER TO ; +``` + +### Configuration +1. Create a random token (for instance using the command `openssl rand -base64 48`, or `./lspd genkey`) +1. Define the environment variables as described in [sample.env](./sample.env). If `CERTMAGIC_DOMAIN` is defined, certificate for this domain is automatically obtained and renewed from Let's Encrypt. In this case, the port needs to be 443. If `CERTMAGIC_DOMAIN` is not defined, lspd needs to run behind a reverse proxy like treafik or nginx. + +ENV variables: +- `LISTEN_ADDRESS` defines the host:port for the lspd grpc server +- `CERTMAGIC_DOMAIN` domain on which lspd will be accessible +- `DATABASE_URL` postgresql db url +- `AWS_REGION` AWS region for SES emailing +- `AWS_ACCESS_KEY_ID` API key for SES emailing +- `AWS_SECRET_ACCESS_KEY`API secret for SES emailing +- `MEMPOOL_API_BASE_URL` uses fee estimation for opening new channels (default: https://mempool.space) +- `MEMPOOL_PRIORITY` priority with which open new channels using mempool api (default: economy) + + + +### Running lspd on CLN +In order to run lspd on top of CLN, you need to run the lspd process and run cln with the provided cln plugin. You also need lightningd compiled with developer mode on (`./configure --enable-developer`) + +The cln plugin (go build -o lspd_plugin cln_plugin/cmd) is best started with a bash script to pass environment variables (note this LISTEN_ADDRESS is the listen address for communication between lspd and the plugin, this is not the listen address mentioned in the 'final step') + +```bash +#!/bin/bash + +export LISTEN_ADDRESS= +/path/to/lspd_plugin +``` + +1. Run cln with the following options set: + - `--plugin=/path/to/shell/script.sh`: to use lspd as plugin + - `--max-concurrent-htlcs=30`: In order to use zero reserve channels on the client side, (local max_accepted_htlcs + remote max_accepted_htlcs + 2) * dust limit must be lower than the channel capacity. Reduce max-concurrent-htlcs or increase channel capacity accordingly. + - `--dev-allowdustreserve=true`: In order to allow zero reserve on the client side (requires developer mode turned on) +1. Run lspd + +### Final step +1. Share with Breez the TOKEN and the LISTEN_ADDRESS you've defined (send to contact@breez.technology) diff --git a/docs/LND.md b/docs/LND.md new file mode 100644 index 0000000..d5a1626 --- /dev/null +++ b/docs/LND.md @@ -0,0 +1,53 @@ + +## Installation instructions for lnd and lspd +### Requirements +- lnd +- lspd +- postgresql + +### Installation +#### LND +Follow LND installation instructions [here](https://github.com/lightningnetwork/lnd/blob/master/docs/INSTALL.md). + +#### lspd +Needs to be build from source: +``` +git clone https://github.com/breez/lspd +cd lspd +go build . # compile lspd +go build -o lspd_plugin # compile lspd cln plugin +``` +### Postgresql +Lspd supports postgresql backend. Create new database and user for lspd: +``` +CREATE ROLE ; +ALTER ROLE WITH NOSUPERUSER INHERIT NOCREATEROLE NOCREATEDB LOGIN NOREPLICATION NOBYPASSRLS PASSWORD ''; +CREATE DATABASE WITH TEMPLATE = template0 ENCODING = 'UTF8' LC_COLLATE = 'en_US.UTF-8' LC_CTYPE = 'en_US.UTF-8'; +ALTER DATABASE OWNER TO ; +`````` + + +### Configure +1. Create a random token (for instance using the command `openssl rand -base64 48`, or `./lspd genkey`) +1. Define the environment variables as described in [sample.env](./sample.env). If `CERTMAGIC_DOMAIN` is defined, certificate for this domain is automatically obtained and renewed from Let's Encrypt. In this case, the port needs to be 443. If `CERTMAGIC_DOMAIN` is not defined, lspd needs to run behind a reverse proxy like treafik or nginx. + +ENV variables: +- `LISTEN_ADDRESS` defines the host:port for the lspd grpc server +- `CERTMAGIC_DOMAIN` domain on which lspd will be accessible +- `DATABASE_URL` postgresql db url +- `AWS_REGION` +- `AWS_ACCESS_KEY_ID` +- `AWS_SECRET_ACCESS_KEY` +- `MEMPOOL_API_BASE_URL` uses fee estimation for opening new channels (default: https://mempool.space) +- `MEMPOOL_PRIORITY` priority with which open new channels using mempool api (default: economy) + +### Running lspd on LND +1. Run LND with the following options set: + - `--protocol.zero-conf`: for being able to open zero conf channels + - `--protocol.option-scid-alias`: required for zero conf channels + - `--requireinterceptor`: to make sure all htlcs are intercepted by lspd + - `--bitcoin.chanreservescript="0"` to allow the client to have zero reserve on their side +1. Run lspd + +### Final step +1. Share with Breez the TOKEN and the LISTEN_ADDRESS you've defined (send to contact@breez.technology) diff --git a/docs/aws.md b/docs/aws.md new file mode 100644 index 0000000..dd76d3a --- /dev/null +++ b/docs/aws.md @@ -0,0 +1,42 @@ +## Automated deployment of LSPD stack to AWS +Cloudformation template for automated deployment of lspd, bitcoind and cln with postgresql backend. +### Requirements +- AWS account +- AWS SES configured + +### Deployment +[Cloudformation template](../deploy/deploy.yml) will automatically deploy several things: +- new ec2 instance (m6a.xlarge) to your selected VPC +- bitcoind +- clnd (with postgresql as backend) +- lspd + +### After deployment steps +#### Configure email notifications + +Edit file ```/home/lspd/.env```. + +1) set your SES credentials: +``` +AWS_REGION="" +AWS_ACCESS_KEY_ID="" +AWS_SECRET_ACCESS_KEY="" +``` + +2) configure email +``` +OPENCHANNEL_NOTIFICATION_TO='["REPLACE ME "]' +OPENCHANNEL_NOTIFICATION_CC='["REPLACE ME "]' +OPENCHANNEL_NOTIFICATION_FROM="test@example.com" + +CHANNELMISMATCH_NOTIFICATION_TO='["REPLACE ME "]' +CHANNELMISMATCH_NOTIFICATION_CC='["REPLACE ME "]' +CHANNELMISMATCH_NOTIFICATION_FROM="replaceme@example.com" +``` +#### Backup credentials + +All credentials are generated automatically and are written down in ```/home/lspd/credentials.txt``` + +**Store them securely and delete the file.** +### Debugging +Log file of deployment is written to ```/tmp/deployment.log``` where you can see the entire output of what happend during deployment. \ No newline at end of file diff --git a/docs/bash.md b/docs/bash.md new file mode 100644 index 0000000..8c4900e --- /dev/null +++ b/docs/bash.md @@ -0,0 +1,57 @@ +## Automated install of LSPD stack for linux +### Requirements +- ubuntu or debian based distribution +- AWS SES credentials +- root / user without sudo password + +### Installation +To install bitcoind,cln and lspd to your system simply run: + +```curl -sL https://raw.githubusercontent.com/breez/lspd/master/deploy/lspd-install.sh | sudo bash -``` + +It will automatically configure your server and install all needed dependencies for running LSPD stack. You will have to manually change the name of your LSPD and your cln alias. + +LSPD: + +``` +vim /home/lspd/.env +# change the name variable in the last line, it will have randomly generated name like "lsp-53v4" +NODES='[ { "name": "${LSPName}" +``` + +CLN: +``` +vim /home/lightning/.lightning/config +# change alias row, it will have randomly generated name like "lsp-53v4" +alias="${LSPName}" +``` + +### After deployment steps +#### Configure email notifications + +Edit file ```/home/lspd/.env```. + +1) set your SES credentials: +``` +AWS_REGION="" +AWS_ACCESS_KEY_ID="" +AWS_SECRET_ACCESS_KEY="" +``` + +2) configure email +``` +OPENCHANNEL_NOTIFICATION_TO='["REPLACE ME "]' +OPENCHANNEL_NOTIFICATION_CC='["REPLACE ME "]' +OPENCHANNEL_NOTIFICATION_FROM="test@example.com" + +CHANNELMISMATCH_NOTIFICATION_TO='["REPLACE ME "]' +CHANNELMISMATCH_NOTIFICATION_CC='["REPLACE ME "]' +CHANNELMISMATCH_NOTIFICATION_FROM="replaceme@example.com" +``` +#### Backup credentials + +All credentials are generated automatically and are written down in ```/home/lspd/credentials.txt``` + +**Store them securely and delete the file.** +### Debugging +Log file of deployment is written to ```/tmp/deployment.log``` where you can see the entire output of what happend during deployment. \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1647f1e --- /dev/null +++ b/go.mod @@ -0,0 +1,194 @@ +module github.com/breez/lspd + +go 1.19 + +require ( + github.com/aws/aws-sdk-go v1.34.0 + github.com/breez/lntest v0.0.26 + github.com/btcsuite/btcd v0.23.5-0.20230228185050-38331963bddd + github.com/btcsuite/btcd/btcec/v2 v2.3.2 + github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2 + github.com/caddyserver/certmagic v0.11.2 + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 + github.com/docker/docker v20.10.24+incompatible + github.com/docker/go-connections v0.4.0 + github.com/elementsproject/glightning v0.0.0-20230525134205-ef34d849f564 + github.com/golang/protobuf v1.5.2 + github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 + github.com/jackc/pgtype v1.8.1 + github.com/jackc/pgx/v4 v4.13.0 + github.com/lightningnetwork/lightning-onion v1.2.1-0.20221202012345-ca23184850a1 + github.com/lightningnetwork/lnd v0.16.2-beta + github.com/lightningnetwork/lnd/tlv v1.1.0 + github.com/stretchr/testify v1.8.1 + go.starlark.net v0.0.0-20230612165344-9532f5667272 + golang.org/x/exp v0.0.0-20230321023759-10a507213a29 + golang.org/x/sync v0.1.0 + google.golang.org/grpc v1.50.1 + google.golang.org/protobuf v1.27.1 +) + +require ( + github.com/Microsoft/go-winio v0.5.2 // indirect + github.com/Yawning/aez v0.0.0-20211027044916-e49e68abd344 // indirect + github.com/docker/distribution v2.8.2+incompatible // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ethereum/go-ethereum v1.10.17 // indirect + github.com/golang-jwt/jwt/v4 v4.4.2 // indirect + github.com/golang/protobuf v1.5.2 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/lightninglabs/neutrino/cache v1.1.1 // indirect + github.com/mattn/go-isatty v0.0.16 // indirect + github.com/moby/term v0.0.0-20221120202655-abb19827d345 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.0.2 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect + gitlab.com/yawning/bsaes.git v0.0.0-20190805113838-0a714cd429ec // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.0.1 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.0.1 // indirect + golang.org/x/mod v0.8.0 // indirect + golang.org/x/net v0.8.0 // indirect + golang.org/x/tools v0.6.0 // indirect + gotest.tools/v3 v3.4.0 // indirect + lukechampine.com/uint128 v1.2.0 // indirect + modernc.org/cc/v3 v3.40.0 // indirect + modernc.org/ccgo/v3 v3.16.13 // indirect + modernc.org/libc v1.22.2 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.4.0 // indirect + modernc.org/opt v0.1.3 // indirect + modernc.org/sqlite v1.20.3 // indirect + modernc.org/strutil v1.1.3 // indirect + modernc.org/token v1.0.1 // indirect +) + +require ( + github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da // indirect + github.com/aead/siphash v1.0.1 // indirect + github.com/andybalholm/brotli v1.0.3 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/btcsuite/btcd/btcutil v1.1.3 // indirect + github.com/btcsuite/btcd/btcutil/psbt v1.1.8 // indirect + github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f // indirect + github.com/btcsuite/btcwallet v0.16.9 // indirect + github.com/btcsuite/btcwallet/wallet/txauthor v1.3.2 // indirect + github.com/btcsuite/btcwallet/wallet/txrules v1.2.0 // indirect + github.com/btcsuite/btcwallet/wallet/txsizes v1.2.3 // indirect + github.com/btcsuite/btcwallet/walletdb v1.4.0 // indirect + github.com/btcsuite/btcwallet/wtxmgr v1.5.0 // indirect + github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd // indirect + github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 // indirect + github.com/btcsuite/winsvc v1.0.0 // indirect + github.com/cenkalti/backoff/v4 v4.1.1 // indirect + github.com/cespare/xxhash/v2 v2.1.1 // indirect + github.com/coreos/go-semver v0.3.0 // indirect + github.com/coreos/go-systemd/v22 v22.3.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/decred/dcrd/crypto/blake256 v1.0.0 // indirect + github.com/decred/dcrd/lru v1.0.0 // indirect + github.com/dsnet/compress v0.0.1 // indirect + github.com/dustin/go-humanize v1.0.0 // indirect + github.com/ecies/go/v2 v2.0.4 + github.com/fergusstrange/embedded-postgres v1.10.0 // indirect + github.com/go-acme/lego/v3 v3.7.0 // indirect + github.com/go-errors/errors v1.0.1 // indirect + github.com/gofrs/uuid v4.2.0+incompatible // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/google/btree v1.0.1 // indirect + github.com/gorilla/websocket v1.4.2 // indirect + github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect + github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.5.0 // indirect + github.com/jackc/chunkreader/v2 v2.0.1 // indirect + github.com/jackc/pgconn v1.10.0 // indirect + github.com/jackc/pgio v1.0.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgproto3/v2 v2.1.1 // indirect + github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect + github.com/jackc/puddle v1.1.3 // indirect + github.com/jessevdk/go-flags v1.4.0 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/jonboulle/clockwork v0.2.2 // indirect + github.com/jrick/logrotate v1.0.0 // indirect + github.com/json-iterator/go v1.1.11 // indirect + github.com/juju/clock v1.0.2 // indirect + github.com/juju/errors v1.0.0 // indirect + github.com/juju/loggo v1.0.0 // indirect + github.com/juju/testing v1.0.1 // indirect + github.com/kkdai/bstream v1.0.0 // indirect + github.com/klauspost/compress v1.13.6 // indirect + github.com/klauspost/cpuid v1.2.3 // indirect + github.com/klauspost/pgzip v1.2.5 // indirect + github.com/kr/pretty v0.3.0 // indirect + github.com/lib/pq v1.10.3 // indirect + github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf // indirect + github.com/lightninglabs/neutrino v0.15.0 // indirect + github.com/lightningnetwork/lnd/clock v1.1.0 // indirect + github.com/lightningnetwork/lnd/healthcheck v1.2.2 // indirect + github.com/lightningnetwork/lnd/kvdb v1.4.1 // indirect + github.com/lightningnetwork/lnd/queue v1.1.0 // indirect + github.com/lightningnetwork/lnd/ticker v1.1.0 // indirect + github.com/lightningnetwork/lnd/tor v1.1.0 // indirect + github.com/ltcsuite/ltcd v0.0.0-20190101042124-f37f8bf35796 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect + github.com/mholt/archiver/v3 v3.5.0 // indirect + github.com/miekg/dns v1.1.43 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.1 // indirect + github.com/nwaples/rardecode v1.1.2 // indirect + github.com/pierrec/lz4/v4 v4.1.8 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_golang v1.11.1 // indirect + github.com/prometheus/client_model v0.2.0 // indirect + github.com/prometheus/common v0.26.0 // indirect + github.com/prometheus/procfs v0.6.0 // indirect + github.com/rogpeppe/fastuuid v1.2.0 // indirect + github.com/rogpeppe/go-internal v1.8.1 // indirect + github.com/sirupsen/logrus v1.7.0 // indirect + github.com/soheilhy/cmux v0.1.5 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/stretchr/objx v0.5.0 // indirect + github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 // indirect + github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802 // indirect + github.com/ulikunitz/xz v0.5.10 // indirect + github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect + github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 // indirect + go.etcd.io/bbolt v1.3.6 // indirect + go.etcd.io/etcd/api/v3 v3.5.7 // indirect + go.etcd.io/etcd/client/pkg/v3 v3.5.7 // indirect + go.etcd.io/etcd/client/v2 v2.305.7 // indirect + go.etcd.io/etcd/client/v3 v3.5.7 // indirect + go.etcd.io/etcd/pkg/v3 v3.5.7 // indirect + go.etcd.io/etcd/raft/v3 v3.5.7 // indirect + go.etcd.io/etcd/server/v3 v3.5.7 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.25.0 // indirect + go.opentelemetry.io/otel v1.0.1 // indirect + go.opentelemetry.io/otel/sdk v1.0.1 // indirect + go.opentelemetry.io/otel/trace v1.0.1 // indirect + go.opentelemetry.io/proto/otlp v0.9.0 // indirect + go.uber.org/atomic v1.7.0 // indirect + go.uber.org/multierr v1.8.0 // indirect + go.uber.org/zap v1.17.0 // indirect + golang.org/x/crypto v0.7.0 // indirect + golang.org/x/sys v0.6.0 // indirect + golang.org/x/term v0.6.0 // indirect + golang.org/x/text v0.8.0 // indirect + golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba // indirect + google.golang.org/genproto v0.0.0-20210617175327-b9e0b3197ced // indirect + gopkg.in/errgo.v1 v1.0.1 // indirect + gopkg.in/macaroon-bakery.v2 v2.0.1 // indirect + gopkg.in/macaroon.v2 v2.0.0 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect + gopkg.in/square/go-jose.v2 v2.3.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + sigs.k8s.io/yaml v1.2.0 // indirect +) + +replace github.com/lightningnetwork/lnd v0.16.2-beta => github.com/breez/lnd v0.15.0-beta.rc6.0.20230501134702-cebcdf1b17fd + +replace github.com/elementsproject/glightning => github.com/breez/glightning v0.0.1-breez diff --git a/grpc_server.go b/grpc_server.go new file mode 100644 index 0000000..d3055a5 --- /dev/null +++ b/grpc_server.go @@ -0,0 +1,201 @@ +package main + +import ( + "context" + "crypto/tls" + "encoding/hex" + "fmt" + "log" + "net" + "strings" + + "github.com/breez/lspd/cln" + "github.com/breez/lspd/config" + "github.com/breez/lspd/lightning" + "github.com/breez/lspd/lnd" + "github.com/breez/lspd/notifications" + lspdrpc "github.com/breez/lspd/rpc" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/caddyserver/certmagic" + ecies "github.com/ecies/go/v2" + grpc_middleware "github.com/grpc-ecosystem/go-grpc-middleware" + "golang.org/x/sync/singleflight" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +type grpcServer struct { + address string + certmagicDomain string + lis net.Listener + s *grpc.Server + nodes map[string]*node + c lspdrpc.ChannelOpenerServer + n notifications.NotificationsServer +} + +type nodeContext struct { + token string + node *node +} + +type node struct { + client lightning.Client + nodeConfig *config.NodeConfig + privateKey *btcec.PrivateKey + publicKey *btcec.PublicKey + eciesPrivateKey *ecies.PrivateKey + eciesPublicKey *ecies.PublicKey + openChannelReqGroup singleflight.Group +} + +func NewGrpcServer( + configs []*config.NodeConfig, + address string, + certmagicDomain string, + c lspdrpc.ChannelOpenerServer, + n notifications.NotificationsServer, +) (*grpcServer, error) { + if len(configs) == 0 { + return nil, fmt.Errorf("no nodes supplied") + } + + nodes := make(map[string]*node) + for _, config := range configs { + pk, err := hex.DecodeString(config.LspdPrivateKey) + if err != nil { + return nil, fmt.Errorf("hex.DecodeString(config.lspdPrivateKey=%v) error: %v", config.LspdPrivateKey, err) + } + + eciesPrivateKey := ecies.NewPrivateKeyFromBytes(pk) + eciesPublicKey := eciesPrivateKey.PublicKey + privateKey, publicKey := btcec.PrivKeyFromBytes(pk) + + node := &node{ + nodeConfig: config, + privateKey: privateKey, + publicKey: publicKey, + eciesPrivateKey: eciesPrivateKey, + eciesPublicKey: eciesPublicKey, + } + + if config.Lnd == nil && config.Cln == nil { + return nil, fmt.Errorf("node has to be either cln or lnd") + } + + if config.Lnd != nil && config.Cln != nil { + return nil, fmt.Errorf("node cannot be both cln and lnd") + } + + if config.Lnd != nil { + node.client, err = lnd.NewLndClient(config.Lnd) + if err != nil { + return nil, err + } + } + + if config.Cln != nil { + node.client, err = cln.NewClnClient(config.Cln.SocketPath) + if err != nil { + return nil, err + } + } + + for _, token := range config.Tokens { + _, exists := nodes[token] + if exists { + return nil, fmt.Errorf("cannot have multiple nodes with the same token") + } + + nodes[token] = node + } + } + + return &grpcServer{ + address: address, + certmagicDomain: certmagicDomain, + nodes: nodes, + c: c, + n: n, + }, nil +} + +func (s *grpcServer) Start() error { + // Make sure all nodes are available and set name and pubkey if not set + // in config. + for _, n := range s.nodes { + info, err := n.client.GetInfo() + if err != nil { + return fmt.Errorf("failed to get info from host %s", n.nodeConfig.Host) + } + + if n.nodeConfig.Name == "" { + n.nodeConfig.Name = info.Alias + } + + if n.nodeConfig.NodePubkey == "" { + n.nodeConfig.NodePubkey = info.Pubkey + } + } + + var lis net.Listener + if s.certmagicDomain == "" { + var err error + lis, err = net.Listen("tcp", s.address) + if err != nil { + log.Fatalf("failed to listen: %v", err) + } + } else { + tlsConfig, err := certmagic.TLS([]string{s.certmagicDomain}) + if err != nil { + log.Fatalf("failed to run certmagic: %v", err) + } + lis, err = tls.Listen("tcp", s.address, tlsConfig) + if err != nil { + log.Fatalf("failed to listen: %v", err) + } + } + + srv := grpc.NewServer( + grpc_middleware.WithUnaryServerChain(func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { + if md, ok := metadata.FromIncomingContext(ctx); ok { + for _, auth := range md.Get("authorization") { + if !strings.HasPrefix(auth, "Bearer ") { + continue + } + + token := strings.Replace(auth, "Bearer ", "", 1) + node, ok := s.nodes[token] + if !ok { + continue + } + + return handler(context.WithValue(ctx, contextKey("node"), &nodeContext{ + token: token, + node: node, + }), req) + } + } + return nil, status.Errorf(codes.PermissionDenied, "Not authorized") + }), + ) + lspdrpc.RegisterChannelOpenerServer(srv, s.c) + notifications.RegisterNotificationsServer(srv, s.n) + + s.s = srv + s.lis = lis + if err := srv.Serve(lis); err != nil { + return fmt.Errorf("failed to serve: %v", err) + } + + return nil +} + +func (s *grpcServer) Stop() { + srv := s.s + if srv != nil { + srv.GracefulStop() + } +} diff --git a/interceptor/email.go b/interceptor/email.go new file mode 100644 index 0000000..8777cac --- /dev/null +++ b/interceptor/email.go @@ -0,0 +1,175 @@ +package interceptor + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "html/template" + "log" + "os" + "strconv" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/ses" +) + +const ( + charset = "UTF-8" +) + +func addresses(a string) (addr []*string) { + json.Unmarshal([]byte(a), &addr) + return +} + +func sendEmail(to, cc, from, content, subject string) error { + + sess, err := session.NewSession(&aws.Config{}) + if err != nil { + log.Printf("Error in session.NewSession: %v", err) + return err + } + svc := ses.New(sess) + + input := &ses.SendEmailInput{ + Destination: &ses.Destination{ + CcAddresses: addresses(cc), + ToAddresses: addresses(to), + }, + Message: &ses.Message{ + Body: &ses.Body{ + Html: &ses.Content{ + Charset: aws.String(charset), + Data: aws.String(content), + }, + }, + Subject: &ses.Content{ + Charset: aws.String(charset), + Data: aws.String(subject), + }, + }, + Source: aws.String(from), + } + // Attempt to send the email. + result, err := svc.SendEmail(input) + if err != nil { + if aerr, ok := err.(awserr.Error); ok { + switch aerr.Code() { + case ses.ErrCodeMessageRejected: + log.Println(ses.ErrCodeMessageRejected, aerr.Error()) + case ses.ErrCodeMailFromDomainNotVerifiedException: + log.Println(ses.ErrCodeMailFromDomainNotVerifiedException, aerr.Error()) + case ses.ErrCodeConfigurationSetDoesNotExistException: + log.Println(ses.ErrCodeConfigurationSetDoesNotExistException, aerr.Error()) + default: + log.Println(aerr.Error()) + } + } else { + // Print the error, cast err to awserr.Error to get the Code and + // Message from an error. + log.Println(err.Error()) + } + return err + } + + log.Printf("Email sent with result:\n%v", result) + + return nil +} + +func sendChannelMismatchNotification(nodeID string, notFakeChannels, closedChannels map[string]uint64) error { + var html bytes.Buffer + + tpl := ` +

NodeID: {{ .NodeID }}

+ {{ if .NotFakeChannels }}

Channels not fake anynmore

+ + {{ range $key, $value := .NotFakeChannels }}{{ end }} +
Channel PointHeight Hint
{{ $key }}{{ $value }}
{{ end }} + {{ if .ClosedChannels }}

Closed Channels

+ + {{ range $key, $value := .ClosedChannels }}{{ end }} +
Channel PointHeight Hint
{{ $key }}{{ $value }}
{{ end }} + ` + t, err := template.New("ChannelMismatchEmail").Parse(tpl) + if err != nil { + return err + } + + if err := t.Execute(&html, struct { + NodeID string + NotFakeChannels map[string]uint64 + ClosedChannels map[string]uint64 + }{nodeID, notFakeChannels, closedChannels}); err != nil { + return err + } + + err = sendEmail( + os.Getenv("CHANNELMISMATCH_NOTIFICATION_TO"), + os.Getenv("CHANNELMISMATCH_NOTIFICATION_CC"), + os.Getenv("CHANNELMISMATCH_NOTIFICATION_FROM"), + html.String(), + "Channel(s) Mismatch", + ) + if err != nil { + log.Printf("Error sending open channel email: %v", err) + return err + } + + return nil +} + +func sendOpenChannelEmailNotification( + paymentHash []byte, incomingAmountMsat int64, + destination []byte, capacity int64, + channelPoint string, + tag *string, +) error { + var html bytes.Buffer + + tpl := ` + + + + + + + +
Payment Hash:{{ .PaymentHash }}
Incoming Amount (msat):{{ .IncomingAmountMsat }}
Destination Node:{{ .Destination }}
Channel capacity (sat):{{ .Capacity }}
Channel point:{{ .ChannelPoint }}
Tag:{{ .Tag }}
+ ` + t, err := template.New("OpenChannelEmail").Parse(tpl) + if err != nil { + return err + } + + tagStr := "" + if tag != nil { + tagStr = *tag + } + if err := t.Execute(&html, map[string]string{ + "PaymentHash": hex.EncodeToString(paymentHash), + "IncomingAmountMsat": strconv.FormatUint(uint64(incomingAmountMsat), 10), + "Destination": hex.EncodeToString(destination), + "Capacity": strconv.FormatUint(uint64(capacity), 10), + "ChannelPoint": channelPoint, + "Tag": tagStr, + }); err != nil { + return err + } + + err = sendEmail( + os.Getenv("OPENCHANNEL_NOTIFICATION_TO"), + os.Getenv("OPENCHANNEL_NOTIFICATION_CC"), + os.Getenv("OPENCHANNEL_NOTIFICATION_FROM"), + html.String(), + "Open Channel - Interceptor", + ) + if err != nil { + log.Printf("Error sending open channel email: %v", err) + return err + } + + return nil +} diff --git a/interceptor/htlc_interceptor.go b/interceptor/htlc_interceptor.go new file mode 100644 index 0000000..f824b36 --- /dev/null +++ b/interceptor/htlc_interceptor.go @@ -0,0 +1,7 @@ +package interceptor + +type HtlcInterceptor interface { + Start() error + Stop() error + WaitStarted() +} diff --git a/interceptor/intercept.go b/interceptor/intercept.go new file mode 100644 index 0000000..9bbb0cd --- /dev/null +++ b/interceptor/intercept.go @@ -0,0 +1,427 @@ +package interceptor + +import ( + "bytes" + "context" + "encoding/hex" + "fmt" + "log" + "math/big" + "time" + + "github.com/breez/lspd/basetypes" + "github.com/breez/lspd/chain" + "github.com/breez/lspd/config" + "github.com/breez/lspd/lightning" + "github.com/breez/lspd/notifications" + "github.com/btcsuite/btcd/wire" + "golang.org/x/sync/singleflight" +) + +type InterceptAction int + +const ( + INTERCEPT_RESUME InterceptAction = 0 + INTERCEPT_RESUME_WITH_ONION InterceptAction = 1 + INTERCEPT_FAIL_HTLC_WITH_CODE InterceptAction = 2 +) + +type InterceptFailureCode uint16 + +var ( + FAILURE_TEMPORARY_CHANNEL_FAILURE InterceptFailureCode = 0x1007 + FAILURE_TEMPORARY_NODE_FAILURE InterceptFailureCode = 0x2002 + FAILURE_INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS InterceptFailureCode = 0x400F +) + +type InterceptResult struct { + Action InterceptAction + FailureCode InterceptFailureCode + Destination []byte + AmountMsat uint64 + TotalAmountMsat uint64 + ChannelPoint *wire.OutPoint + ChannelId uint64 + PaymentSecret []byte +} + +type Interceptor struct { + client lightning.Client + config *config.NodeConfig + store InterceptStore + feeEstimator chain.FeeEstimator + feeStrategy chain.FeeStrategy + payHashGroup singleflight.Group + notificationService *notifications.NotificationService +} + +func NewInterceptor( + client lightning.Client, + config *config.NodeConfig, + store InterceptStore, + feeEstimator chain.FeeEstimator, + feeStrategy chain.FeeStrategy, + notificationService *notifications.NotificationService, +) *Interceptor { + return &Interceptor{ + client: client, + config: config, + store: store, + feeEstimator: feeEstimator, + feeStrategy: feeStrategy, + notificationService: notificationService, + } +} + +func (i *Interceptor) Intercept(scid *basetypes.ShortChannelID, reqPaymentHash []byte, reqOutgoingAmountMsat uint64, reqOutgoingExpiry uint32, reqIncomingExpiry uint32) InterceptResult { + reqPaymentHashStr := hex.EncodeToString(reqPaymentHash) + resp, _, _ := i.payHashGroup.Do(reqPaymentHashStr, func() (interface{}, error) { + token, params, paymentHash, paymentSecret, destination, incomingAmountMsat, outgoingAmountMsat, channelPoint, tag, err := i.store.PaymentInfo(reqPaymentHash) + if err != nil { + log.Printf("paymentInfo(%x) error: %v", reqPaymentHash, err) + return InterceptResult{ + Action: INTERCEPT_FAIL_HTLC_WITH_CODE, + FailureCode: FAILURE_TEMPORARY_NODE_FAILURE, + }, nil + } + + isRegistered := paymentSecret != nil + // Sanity check. If the payment is registered, the destination is always set. + if isRegistered && (destination == nil || len(destination) != 33) { + log.Printf("ERROR: Payment was registered without destination. paymentHash: %s", reqPaymentHashStr) + } + + isProbe := isRegistered && !bytes.Equal(paymentHash, reqPaymentHash) + nextHop, _ := i.client.GetPeerId(scid) + if err != nil { + log.Printf("GetPeerId(%s) error: %v", scid.ToString(), err) + return InterceptResult{ + Action: INTERCEPT_RESUME, + }, nil + } + + // If the payment was registered, but the next hop is not the destination + // that means we are not the last hop of the payment, so we'll just forward. + if isRegistered && nextHop != nil && !bytes.Equal(nextHop, destination) { + return InterceptResult{ + Action: INTERCEPT_RESUME, + }, nil + } + + // nextHop is set if the sender's scid corresponds to a known channel. + // destination is set if the payment was registered for a channel open. + // The 'actual' next hop will be either of those. + if nextHop == nil { + + // If the next hop cannot be deduced from the scid, and the payment + // is not registered, there's nothing left to be done. Just continue. + if !isRegistered { + return InterceptResult{ + Action: INTERCEPT_RESUME, + }, nil + } + + // The payment was registered, so the next hop is the registered + // destination + nextHop = destination + } + + isConnected, err := i.client.IsConnected(nextHop) + if err != nil { + log.Printf("IsConnected(%x) error: %v", nextHop, err) + return &InterceptResult{ + Action: INTERCEPT_FAIL_HTLC_WITH_CODE, + FailureCode: FAILURE_TEMPORARY_CHANNEL_FAILURE, + }, nil + } + + if isProbe { + // If this is a known probe, we'll quit early for non-connected clients. + if !isConnected { + return InterceptResult{ + Action: INTERCEPT_RESUME, + }, nil + } + + // If it's a probe, they are connected, but need a channel, we'll return + // INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS. This is an out-of-spec + // error code, because it shouldn't be returned by an intermediate + // node. But senders implementnig the probing-01: prefix should + // know that the actual payment would probably succeed. + if channelPoint == nil { + return InterceptResult{ + Action: INTERCEPT_FAIL_HTLC_WITH_CODE, + FailureCode: FAILURE_INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS, + }, nil + } + } + + if !isConnected { + // Make sure the client is connected by potentially notifying them to come online. + notifyResult := i.notify(reqPaymentHashStr, nextHop, isRegistered) + if notifyResult != nil { + return *notifyResult, nil + } + } + + // The peer is online, we can resume the htlc if it's not a channel open. + if !isRegistered { + return InterceptResult{ + Action: INTERCEPT_RESUME, + }, nil + } + + // The first htlc of a MPP will open the channel. + if channelPoint == nil { + // TODO: When opening_fee_params is enforced, turn this check in a temporary channel failure. + if params == nil { + log.Printf("DEPRECATED: Intercepted htlc with deprecated fee mechanism. Using default fees. payment hash: %s", reqPaymentHashStr) + params = &OpeningFeeParams{ + MinMsat: uint64(i.config.ChannelMinimumFeeMsat), + Proportional: uint32(i.config.ChannelFeePermyriad * 100), + ValidUntil: time.Now().UTC().Add(time.Duration(time.Hour * 24)).Format(basetypes.TIME_FORMAT), + MaxIdleTime: uint32(i.config.MaxInactiveDuration / 600), + MaxClientToSelfDelay: uint32(10000), + } + } + + // Make sure the cltv delta is enough. + if int64(reqIncomingExpiry)-int64(reqOutgoingExpiry) < int64(i.config.TimeLockDelta) { + return InterceptResult{ + Action: INTERCEPT_FAIL_HTLC_WITH_CODE, + FailureCode: FAILURE_TEMPORARY_CHANNEL_FAILURE, + }, nil + } + + validUntil, err := time.Parse(basetypes.TIME_FORMAT, params.ValidUntil) + if err != nil { + log.Printf("time.Parse(%s, %s) failed. Failing channel open: %v", basetypes.TIME_FORMAT, params.ValidUntil, err) + return InterceptResult{ + Action: INTERCEPT_FAIL_HTLC_WITH_CODE, + FailureCode: FAILURE_TEMPORARY_CHANNEL_FAILURE, + }, nil + } + + // Make sure the opening_fee_params are not expired. + // If they are expired, but the current chain fee is fine, open channel anyway. + if time.Now().UTC().After(validUntil) { + if !i.isCurrentChainFeeCheaper(token, params) { + log.Printf("Intercepted expired payment registration. Failing payment. payment hash: %x, valid until: %s", paymentHash, params.ValidUntil) + return InterceptResult{ + Action: INTERCEPT_FAIL_HTLC_WITH_CODE, + FailureCode: FAILURE_TEMPORARY_CHANNEL_FAILURE, + }, nil + } + + log.Printf("Intercepted expired payment registration. Opening channel anyway, because it's cheaper at the current rate. paymenthash: %s, params: %+v", reqPaymentHashStr, params) + } + + channelPoint, err = i.openChannel(reqPaymentHash, destination, incomingAmountMsat, tag) + if err != nil { + log.Printf("openChannel(%x, %v) err: %v", destination, incomingAmountMsat, err) + return InterceptResult{ + Action: INTERCEPT_FAIL_HTLC_WITH_CODE, + FailureCode: FAILURE_TEMPORARY_CHANNEL_FAILURE, + }, nil + } + } + + var bigProd, bigAmt big.Int + amt := (bigAmt.Div(bigProd.Mul(big.NewInt(outgoingAmountMsat), big.NewInt(int64(reqOutgoingAmountMsat))), big.NewInt(incomingAmountMsat))).Int64() + + deadline := time.Now().Add(60 * time.Second) + + for { + chanResult, _ := i.client.GetChannel(destination, *channelPoint) + if chanResult != nil { + log.Printf("channel opened successfully alias: %v, confirmed: %v", chanResult.InitialChannelID.ToString(), chanResult.ConfirmedChannelID.ToString()) + + err := i.store.InsertChannel( + uint64(chanResult.InitialChannelID), + uint64(chanResult.ConfirmedChannelID), + channelPoint.String(), + destination, + time.Now(), + ) + + if err != nil { + log.Printf("insertChannel error: %v", err) + return InterceptResult{ + Action: INTERCEPT_FAIL_HTLC_WITH_CODE, + FailureCode: FAILURE_TEMPORARY_CHANNEL_FAILURE, + }, nil + } + + channelID := uint64(chanResult.ConfirmedChannelID) + if channelID == 0 { + channelID = uint64(chanResult.InitialChannelID) + } + + return InterceptResult{ + Action: INTERCEPT_RESUME_WITH_ONION, + Destination: destination, + ChannelPoint: channelPoint, + ChannelId: channelID, + PaymentSecret: paymentSecret, + AmountMsat: uint64(amt), + TotalAmountMsat: uint64(outgoingAmountMsat), + }, nil + } + + log.Printf("waiting for channel to get opened.... %v\n", destination) + if time.Now().After(deadline) { + log.Printf("Stop retrying getChannel(%v, %v)", destination, channelPoint.String()) + break + } + <-time.After(1 * time.Second) + } + + log.Printf("Error: Channel failed to open... timed out. ") + return InterceptResult{ + Action: INTERCEPT_FAIL_HTLC_WITH_CODE, + FailureCode: FAILURE_TEMPORARY_CHANNEL_FAILURE, + }, nil + }) + + return resp.(InterceptResult) +} + +func (i *Interceptor) notify(reqPaymentHashStr string, nextHop []byte, isRegistered bool) *InterceptResult { + // If not connected, send a notification to the registered + // notification service for this client if available. + notified, err := i.notificationService.Notify( + hex.EncodeToString(nextHop), + reqPaymentHashStr, + ) + + // If this errors or the client is not notified, the client + // is offline or unknown. We'll resume the HTLC (which will + // result in UNKOWN_NEXT_PEER) + if err != nil || !notified { + return &InterceptResult{ + Action: INTERCEPT_RESUME, + } + } + + log.Printf("Notified %x of pending htlc", nextHop) + d, err := time.ParseDuration(i.config.NotificationTimeout) + if err != nil { + log.Printf("WARN: No NotificationTimeout set. Using default 1m") + d = time.Minute + } + timeout := time.Now().Add(d) + + // Wait for a while to allow the client to come online. + err = i.client.WaitOnline(nextHop, timeout) + + // If there's an error waiting, resume the htlc. It will + // probably fail with UNKNOWN_NEXT_PEER. + if err != nil { + log.Printf( + "waiting for peer %x to come online failed with %v", + nextHop, + err, + ) + return &InterceptResult{ + Action: INTERCEPT_RESUME, + } + } + + log.Printf("Peer %x is back online. Continue htlc.", nextHop) + // At this point we know a few things. + // - This is either a channel partner or a registered payment + // - they were offline + // - They got notified about the htlc + // - They came back online + // So if this payment was not registered, this is a channel + // partner and we have to wait for the channel to become active + // before we can forward. + if !isRegistered { + err = i.client.WaitChannelActive(nextHop, timeout) + if err != nil { + log.Printf( + "waiting for channnel with %x to become active failed with %v", + nextHop, + err, + ) + return &InterceptResult{ + Action: INTERCEPT_RESUME, + } + } + } + + return nil +} + +func (i *Interceptor) isCurrentChainFeeCheaper(token string, params *OpeningFeeParams) bool { + settings, err := i.store.GetFeeParamsSettings(token) + if err != nil { + log.Printf("Failed to get fee params settings: %v", err) + return false + } + + for _, setting := range settings { + if setting.Params.MinMsat <= params.MinMsat { + return true + } + } + + return false +} + +func (i *Interceptor) openChannel(paymentHash, destination []byte, incomingAmountMsat int64, tag *string) (*wire.OutPoint, error) { + capacity := incomingAmountMsat/1000 + i.config.AdditionalChannelCapacity + if capacity == i.config.PublicChannelAmount { + capacity++ + } + + var targetConf *uint32 + confStr := "" + var feeEstimation *float64 + feeStr := "" + if i.feeEstimator != nil { + fee, err := i.feeEstimator.EstimateFeeRate( + context.Background(), + i.feeStrategy, + ) + if err == nil { + feeEstimation = &fee.SatPerVByte + feeStr = fmt.Sprintf("%.5f", *feeEstimation) + } else { + log.Printf("Error estimating chain fee, fallback to target conf: %v", err) + targetConf = &i.config.TargetConf + confStr = fmt.Sprintf("%v", *targetConf) + } + } + + log.Printf( + "Opening zero conf channel. Destination: %x, capacity: %v, fee: %s, targetConf: %s", + destination, + capacity, + feeStr, + confStr, + ) + channelPoint, err := i.client.OpenChannel(&lightning.OpenChannelRequest{ + Destination: destination, + CapacitySat: uint64(capacity), + MinConfs: i.config.MinConfs, + IsPrivate: true, + IsZeroConf: true, + FeeSatPerVByte: feeEstimation, + TargetConf: targetConf, + }) + if err != nil { + log.Printf("client.OpenChannelSync(%x, %v) error: %v", destination, capacity, err) + return nil, err + } + sendOpenChannelEmailNotification( + paymentHash, + incomingAmountMsat, + destination, + capacity, + channelPoint.String(), + tag, + ) + err = i.store.SetFundingTx(paymentHash, channelPoint) + return channelPoint, err +} diff --git a/interceptor/store.go b/interceptor/store.go new file mode 100644 index 0000000..172281a --- /dev/null +++ b/interceptor/store.go @@ -0,0 +1,28 @@ +package interceptor + +import ( + "time" + + "github.com/btcsuite/btcd/wire" +) + +type OpeningFeeParamsSetting struct { + Validity time.Duration + Params *OpeningFeeParams +} +type OpeningFeeParams struct { + MinMsat uint64 `json:"min_msat,string"` + Proportional uint32 `json:"proportional"` + ValidUntil string `json:"valid_until"` + MaxIdleTime uint32 `json:"max_idle_time"` + MaxClientToSelfDelay uint32 `json:"max_client_to_self_delay"` + Promise string `json:"promise"` +} + +type InterceptStore interface { + PaymentInfo(htlcPaymentHash []byte) (string, *OpeningFeeParams, []byte, []byte, []byte, int64, int64, *wire.OutPoint, *string, error) + SetFundingTx(paymentHash []byte, channelPoint *wire.OutPoint) error + RegisterPayment(token string, params *OpeningFeeParams, destination, paymentHash, paymentSecret []byte, incomingAmountMsat, outgoingAmountMsat int64, tag string) error + InsertChannel(initialChanID, confirmedChanId uint64, channelPoint string, nodeID []byte, lastUpdate time.Time) error + GetFeeParamsSettings(token string) ([]*OpeningFeeParamsSetting, error) +} diff --git a/itest/bob_offline_test.go b/itest/bob_offline_test.go new file mode 100644 index 0000000..9e04fda --- /dev/null +++ b/itest/bob_offline_test.go @@ -0,0 +1,69 @@ +package itest + +import ( + "log" + "time" + + "github.com/breez/lntest" + lspd "github.com/breez/lspd/rpc" + "github.com/stretchr/testify/assert" +) + +func testFailureBobOffline(p *testParams) { + alice := lntest.NewClnNode(p.h, p.m, "Alice") + alice.Start() + alice.Fund(10000000) + p.lsp.LightningNode().Fund(10000000) + + log.Print("Opening channel between Alice and the lsp") + channel := alice.OpenChannel(p.lsp.LightningNode(), &lntest.OpenChannelOptions{ + AmountSat: publicChanAmount, + }) + channelId := alice.WaitForChannelReady(channel) + + log.Printf("Adding bob's invoices") + outerAmountMsat := uint64(2100000) + innerAmountMsat := calculateInnerAmountMsat(p.lsp, outerAmountMsat, nil) + description := "Please pay me" + innerInvoice, outerInvoice := GenerateInvoices(p.BreezClient(), + generateInvoicesRequest{ + innerAmountMsat: innerAmountMsat, + outerAmountMsat: outerAmountMsat, + description: description, + lsp: p.lsp, + }) + + p.BreezClient().SetHtlcAcceptor(innerAmountMsat) + log.Print("Connecting bob to lspd") + p.BreezClient().Node().ConnectPeer(p.lsp.LightningNode()) + + log.Printf("Registering payment with lsp") + RegisterPayment(p.lsp, &lspd.PaymentInformation{ + PaymentHash: innerInvoice.paymentHash, + PaymentSecret: innerInvoice.paymentSecret, + Destination: p.BreezClient().Node().NodeId(), + IncomingAmountMsat: int64(outerAmountMsat), + OutgoingAmountMsat: int64(innerAmountMsat), + }, false) + + // Kill the mobile client + log.Printf("Stopping breez client") + p.BreezClient().Stop() + + // TODO: Fix race waiting for htlc interceptor. + log.Printf("Waiting %v to allow htlc interceptor to activate.", htlcInterceptorDelay) + <-time.After(htlcInterceptorDelay) + + log.Printf("Alice paying") + route := constructRoute(p.lsp.LightningNode(), p.BreezClient().Node(), channelId, lntest.NewShortChanIDFromString("1x0x0"), outerAmountMsat) + _, err := alice.PayViaRoute(outerAmountMsat, outerInvoice.paymentHash, outerInvoice.paymentSecret, route) + assert.Contains(p.t, err.Error(), "WIRE_UNKNOWN_NEXT_PEER") + + log.Printf("Starting breez client again") + p.BreezClient().Start() + p.BreezClient().Node().ConnectPeer(p.lsp.LightningNode()) + + log.Printf("Alice paying again") + _, err = alice.PayViaRoute(outerAmountMsat, outerInvoice.paymentHash, outerInvoice.paymentSecret, route) + assert.Nil(p.t, err) +} diff --git a/itest/breez_client.go b/itest/breez_client.go new file mode 100644 index 0000000..80f1fa8 --- /dev/null +++ b/itest/breez_client.go @@ -0,0 +1,122 @@ +package itest + +import ( + "crypto/sha256" + "log" + "testing" + + "github.com/breez/lntest" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/ecdsa" + "github.com/btcsuite/btcd/chaincfg" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/zpay32" +) + +type BreezClient interface { + Name() string + Harness() *lntest.TestHarness + Node() lntest.LightningNode + Start() + Stop() error + SetHtlcAcceptor(totalMsat uint64) + ResetHtlcAcceptor() +} + +type generateInvoicesRequest struct { + innerAmountMsat uint64 + outerAmountMsat uint64 + description string + lsp LspNode +} + +type invoice struct { + bolt11 string + paymentHash []byte + paymentSecret []byte + paymentPreimage []byte +} + +func GenerateInvoices(n BreezClient, req generateInvoicesRequest) (invoice, invoice) { + preimage, err := GenerateRandomBytes(32) + lntest.CheckError(n.Harness().T, err) + + innerInvoice := n.Node().CreateBolt11Invoice(&lntest.CreateInvoiceOptions{ + AmountMsat: req.innerAmountMsat, + Description: &req.description, + Preimage: &preimage, + }) + outerInvoice := AddHopHint(n, innerInvoice.Bolt11, req.lsp, lntest.ShortChannelID{ + BlockHeight: 1, + TxIndex: 0, + OutputIndex: 0, + }, &req.outerAmountMsat) + + inner := invoice{ + bolt11: innerInvoice.Bolt11, + paymentHash: innerInvoice.PaymentHash, + paymentSecret: innerInvoice.PaymentSecret, + paymentPreimage: preimage, + } + outer := invoice{ + bolt11: outerInvoice, + paymentHash: innerInvoice.PaymentHash[:], + paymentSecret: innerInvoice.PaymentSecret, + paymentPreimage: preimage, + } + + return inner, outer +} + +func ContainsHopHint(t *testing.T, invoice string) bool { + rawInvoice, err := zpay32.Decode(invoice, &chaincfg.RegressionNetParams) + lntest.CheckError(t, err) + + return len(rawInvoice.RouteHints) > 0 +} + +func AddHopHint(n BreezClient, invoice string, lsp LspNode, chanid lntest.ShortChannelID, amountMsat *uint64) string { + rawInvoice, err := zpay32.Decode(invoice, &chaincfg.RegressionNetParams) + lntest.CheckError(n.Harness().T, err) + + if amountMsat != nil { + milliSat := lnwire.MilliSatoshi(*amountMsat) + rawInvoice.MilliSat = &milliSat + } + + lspNodeId, err := btcec.ParsePubKey(lsp.NodeId()) + lntest.CheckError(n.Harness().T, err) + rawInvoice.RouteHints = append(rawInvoice.RouteHints, []zpay32.HopHint{ + { + NodeID: lspNodeId, + ChannelID: chanid.ToUint64(), + FeeBaseMSat: lspBaseFeeMsat, + FeeProportionalMillionths: lspFeeRatePpm, + CLTVExpiryDelta: lspCltvDelta, + }, + }) + + log.Printf( + "Encoding invoice. privkey: '%x', invoice: '%+v', original bolt11: '%s'", + n.Node().PrivateKey().Serialize(), + rawInvoice, + invoice, + ) + newInvoice, err := rawInvoice.Encode(zpay32.MessageSigner{ + SignCompact: func(msg []byte) ([]byte, error) { + hash := sha256.Sum256(msg) + sig, err := ecdsa.SignCompact(n.Node().PrivateKey(), hash[:], true) + log.Printf( + "sign outer invoice. msg: '%x', hash: '%x', sig: '%x', err: %v", + msg, + hash, + sig, + err, + ) + return sig, err + }, + }) + lntest.CheckError(n.Harness().T, err) + + return newInvoice +} diff --git a/itest/cln_breez_client.go b/itest/cln_breez_client.go new file mode 100644 index 0000000..ee2ad66 --- /dev/null +++ b/itest/cln_breez_client.go @@ -0,0 +1,335 @@ +package itest + +import ( + "bufio" + "bytes" + "context" + "encoding/hex" + "fmt" + "io" + "log" + "os" + "path/filepath" + "sync" + "time" + + "github.com/breez/lntest" + "github.com/breez/lspd/cln_plugin/proto" + sphinx "github.com/lightningnetwork/lightning-onion" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/record" + "github.com/lightningnetwork/lnd/tlv" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/keepalive" +) + +var pluginContent string = `#!/usr/bin/env python3 +"""Use the openchannel hook to selectively opt-into zeroconf +""" + +from pyln.client import Plugin + +plugin = Plugin() + + +@plugin.hook('openchannel') +def on_openchannel(openchannel, plugin, **kwargs): + plugin.log(repr(openchannel)) + mindepth = int(0) + + plugin.log(f"This peer is in the zeroconf allowlist, setting mindepth={mindepth}") + return {'result': 'continue', 'mindepth': mindepth} + +plugin.run() +` + +var pluginStartupContent string = `python3 -m venv %s > /dev/null 2>&1 +source %s > /dev/null 2>&1 +pip install pyln-client > /dev/null 2>&1 +python %s +` + +type clnBreezClient struct { + name string + scriptDir string + pluginFilePath string + htlcAcceptorAddress string + htlcAcceptor func(*proto.HtlcAccepted) *proto.HtlcResolution + htlcAcceptorCancel context.CancelFunc + harness *lntest.TestHarness + isInitialized bool + node *lntest.ClnNode + mtx sync.Mutex +} + +func newClnBreezClient(h *lntest.TestHarness, m *lntest.Miner, name string) BreezClient { + scriptDir := h.GetDirectory(name) + pluginFilePath := filepath.Join(scriptDir, "start_zero_conf_plugin.sh") + htlcAcceptorPort, err := lntest.GetPort() + if err != nil { + h.T.Fatalf("Could not get port for htlc acceptor plugin: %v", err) + } + + htlcAcceptorAddress := fmt.Sprintf("127.0.0.1:%v", htlcAcceptorPort) + node := lntest.NewClnNode( + h, + m, + name, + fmt.Sprintf("--plugin=%s", pluginFilePath), + fmt.Sprintf("--plugin=%s", *clnPluginExec), + fmt.Sprintf("--lsp-listen=%s", htlcAcceptorAddress), + // NOTE: max-concurrent-htlcs is 30 on mainnet by default. In cln V22.11 + // there is a check for 'all dust' commitment transactions. The max + // concurrent HTLCs of both sides of the channel * dust limit must be + // lower than the channel capacity in order to open a zero conf zero + // reserve channel. Relevant code: + // https://github.com/ElementsProject/lightning/blob/774d16a72e125e4ae4e312b9e3307261983bec0e/openingd/openingd.c#L481-L520 + "--max-concurrent-htlcs=30", + ) + + return &clnBreezClient{ + name: name, + harness: h, + node: node, + scriptDir: scriptDir, + pluginFilePath: pluginFilePath, + htlcAcceptorAddress: htlcAcceptorAddress, + } +} + +func (c *clnBreezClient) Name() string { + return c.name +} + +func (c *clnBreezClient) Harness() *lntest.TestHarness { + return c.harness +} + +func (c *clnBreezClient) Node() lntest.LightningNode { + return c.node +} + +func (c *clnBreezClient) Start() { + c.mtx.Lock() + defer c.mtx.Unlock() + + if !c.isInitialized { + c.initialize() + c.isInitialized = true + } + + c.node.Start() + c.startHtlcAcceptor() +} + +func (c *clnBreezClient) ResetHtlcAcceptor() { + c.htlcAcceptor = nil +} +func (c *clnBreezClient) SetHtlcAcceptor(totalMsat uint64) { + c.htlcAcceptor = func(htlc *proto.HtlcAccepted) *proto.HtlcResolution { + origPayload, err := hex.DecodeString(htlc.Onion.Payload) + if err != nil { + c.harness.T.Fatalf("failed to hex decode onion payload %s: %v", htlc.Onion.Payload, err) + } + bufReader := bytes.NewBuffer(origPayload) + var b [8]byte + varInt, err := sphinx.ReadVarInt(bufReader, &b) + if err != nil { + c.harness.T.Fatalf("Failed to read payload: %v", err) + } + + innerPayload := make([]byte, varInt) + if _, err := io.ReadFull(bufReader, innerPayload[:]); err != nil { + c.harness.T.Fatalf("failed to decode payload %x: %v", innerPayload[:], err) + } + + s, _ := tlv.NewStream() + tlvMap, err := s.DecodeWithParsedTypes(bytes.NewReader(innerPayload)) + if err != nil { + c.harness.T.Fatalf("DecodeWithParsedTypes failed for %x: %v", innerPayload[:], err) + } + + amt := record.NewAmtToFwdRecord(&htlc.Htlc.AmountMsat) + amtbuf := bytes.NewBuffer([]byte{}) + if err := amt.Encode(amtbuf); err != nil { + c.harness.T.Fatalf("failed to encode AmtToFwd %x: %v", innerPayload[:], err) + } + + uTlvMap := make(map[uint64][]byte) + for t, b := range tlvMap { + if t == record.AmtOnionType { + uTlvMap[uint64(t)] = amtbuf.Bytes() + continue + } + + if t == record.MPPOnionType { + addr := [32]byte{} + copy(addr[:], b[:32]) + mppbuf := bytes.NewBuffer([]byte{}) + mpp := record.NewMPP( + lnwire.MilliSatoshi(totalMsat), + addr, + ) + record := mpp.Record() + record.Encode(mppbuf) + uTlvMap[uint64(t)] = mppbuf.Bytes() + continue + } + + uTlvMap[uint64(t)] = b + } + tlvRecords := tlv.MapToRecords(uTlvMap) + s, err = tlv.NewStream(tlvRecords...) + if err != nil { + c.harness.T.Fatalf("tlv.NewStream(%+v) error: %v", tlvRecords, err) + } + var newPayloadBuf bytes.Buffer + err = s.Encode(&newPayloadBuf) + if err != nil { + c.harness.T.Fatalf("encode error: %v", err) + } + payload := hex.EncodeToString(newPayloadBuf.Bytes()) + + return &proto.HtlcResolution{ + Correlationid: htlc.Correlationid, + Outcome: &proto.HtlcResolution_Continue{ + Continue: &proto.HtlcContinue{ + Payload: &payload, + }, + }, + } + } +} + +func (c *clnBreezClient) startHtlcAcceptor() { + ctx, cancel := context.WithCancel(c.harness.Ctx) + c.htlcAcceptorCancel = cancel + + go func() { + for { + if ctx.Err() != nil { + return + } + + select { + case <-ctx.Done(): + return + case <-time.After(time.Second): + } + + conn, err := grpc.DialContext( + ctx, + c.htlcAcceptorAddress, + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithKeepaliveParams(keepalive.ClientParameters{ + Time: time.Duration(10) * time.Second, + Timeout: time.Duration(10) * time.Second, + }), + ) + if err != nil { + log.Printf("%s: Dial htlc acceptor error: %v", c.name, err) + continue + } + + client := proto.NewClnPluginClient(conn) + acceptor, err := client.HtlcStream(ctx) + if err != nil { + log.Printf("%s: client.HtlcStream() error: %v", c.name, err) + break + } + for { + htlc, err := acceptor.Recv() + if err != nil { + log.Printf("%s: acceptor.Recv() error: %v", c.name, err) + break + } + + f := c.htlcAcceptor + var resp *proto.HtlcResolution + if f != nil { + resp = f(htlc) + } + if resp == nil { + resp = &proto.HtlcResolution{ + Correlationid: htlc.Correlationid, + Outcome: &proto.HtlcResolution_Continue{ + Continue: &proto.HtlcContinue{}, + }, + } + } + + err = acceptor.Send(resp) + if err != nil { + log.Printf("%s: acceptor.Send() error: %v", c.name, err) + break + } + } + } + }() +} + +func (c *clnBreezClient) initialize() error { + var cleanups []*lntest.Cleanup + + pythonFilePath := filepath.Join(c.scriptDir, "zero_conf_plugin.py") + pythonFile, err := os.OpenFile(pythonFilePath, os.O_CREATE|os.O_WRONLY, 0755) + if err != nil { + return fmt.Errorf("failed to create python file '%s': %v", pythonFilePath, err) + } + cleanups = append(cleanups, &lntest.Cleanup{ + Name: fmt.Sprintf("%s: python file", c.name), + Fn: pythonFile.Close, + }) + + pythonWriter := bufio.NewWriter(pythonFile) + _, err = pythonWriter.WriteString(pluginContent) + if err != nil { + lntest.PerformCleanup(cleanups) + return fmt.Errorf("failed to write content to python file '%s': %v", pythonFilePath, err) + } + + err = pythonWriter.Flush() + if err != nil { + lntest.PerformCleanup(cleanups) + return fmt.Errorf("failed to flush python file '%s': %v", pythonFilePath, err) + } + + pluginFile, err := os.OpenFile(c.pluginFilePath, os.O_CREATE|os.O_WRONLY, 0755) + if err != nil { + lntest.PerformCleanup(cleanups) + return fmt.Errorf("failed to create plugin file '%s': %v", c.pluginFilePath, err) + } + cleanups = append(cleanups, &lntest.Cleanup{ + Name: fmt.Sprintf("%s: python file", c.name), + Fn: pluginFile.Close, + }) + + pluginWriter := bufio.NewWriter(pluginFile) + venvDir := filepath.Join(c.scriptDir, "venv") + activatePath := filepath.Join(venvDir, "bin", "activate") + _, err = pluginWriter.WriteString(fmt.Sprintf(pluginStartupContent, venvDir, activatePath, pythonFilePath)) + if err != nil { + lntest.PerformCleanup(cleanups) + return fmt.Errorf("failed to write content to plugin file '%s': %v", c.pluginFilePath, err) + } + + err = pluginWriter.Flush() + if err != nil { + lntest.PerformCleanup(cleanups) + return fmt.Errorf("failed to flush plugin file '%s': %v", c.pluginFilePath, err) + } + + lntest.PerformCleanup(cleanups) + return nil +} + +func (c *clnBreezClient) Stop() error { + c.mtx.Lock() + defer c.mtx.Unlock() + if c.htlcAcceptorCancel != nil { + c.htlcAcceptorCancel() + c.htlcAcceptorCancel = nil + } + return c.node.Stop() +} diff --git a/itest/cln_lspd_node.go b/itest/cln_lspd_node.go new file mode 100644 index 0000000..fcfa646 --- /dev/null +++ b/itest/cln_lspd_node.go @@ -0,0 +1,228 @@ +package itest + +import ( + "flag" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "sync" + "syscall" + + "github.com/breez/lntest" + "github.com/breez/lspd/config" + "github.com/breez/lspd/notifications" + lspd "github.com/breez/lspd/rpc" + "github.com/btcsuite/btcd/btcec/v2" + ecies "github.com/ecies/go/v2" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +var clnPluginExec = flag.String( + "clnpluginexec", "", "full path to cln plugin wrapper binary", +) + +type ClnLspNode struct { + harness *lntest.TestHarness + lightningNode *lntest.ClnNode + lspBase *lspBase + logFilePath string + runtime *clnLspNodeRuntime + isInitialized bool + mtx sync.Mutex + pluginBinary string + pluginAddress string +} + +type clnLspNodeRuntime struct { + logFile *os.File + cmd *exec.Cmd + rpc lspd.ChannelOpenerClient + notificationRpc notifications.NotificationsClient + cleanups []*lntest.Cleanup +} + +func NewClnLspdNode(h *lntest.TestHarness, m *lntest.Miner, mem *mempoolApi, name string, nodeConfig *config.NodeConfig) LspNode { + scriptDir := h.GetDirectory("lspd") + pluginBinary := *clnPluginExec + pluginPort, err := lntest.GetPort() + if err != nil { + h.T.Fatalf("failed to get port for the htlc interceptor plugin.") + } + pluginAddress := fmt.Sprintf("127.0.0.1:%d", pluginPort) + + args := []string{ + fmt.Sprintf("--plugin=%s", pluginBinary), + fmt.Sprintf("--lsp-listen=%s", pluginAddress), + fmt.Sprintf("--fee-base=%d", lspBaseFeeMsat), + fmt.Sprintf("--fee-per-satoshi=%d", lspFeeRatePpm), + fmt.Sprintf("--cltv-delta=%d", lspCltvDelta), + "--max-concurrent-htlcs=30", + "--dev-allowdustreserve=true", + "--allow-deprecated-apis=true", + } + lightningNode := lntest.NewClnNode(h, m, name, args...) + cln := &config.ClnConfig{ + PluginAddress: pluginAddress, + SocketPath: filepath.Join(lightningNode.SocketDir(), lightningNode.SocketFile()), + } + lspbase, err := newLspd(h, mem, name, nodeConfig, nil, cln) + if err != nil { + h.T.Fatalf("failed to initialize lspd") + } + + logFilePath := filepath.Join(scriptDir, "lspd.log") + h.RegisterLogfile(logFilePath, fmt.Sprintf("lspd-%s", name)) + + lspNode := &ClnLspNode{ + harness: h, + lightningNode: lightningNode, + logFilePath: logFilePath, + lspBase: lspbase, + pluginBinary: pluginBinary, + pluginAddress: pluginAddress, + } + + h.AddStoppable(lspNode) + return lspNode +} + +func (c *ClnLspNode) Start() { + c.mtx.Lock() + defer c.mtx.Unlock() + + var cleanups []*lntest.Cleanup + if !c.isInitialized { + err := c.lspBase.Initialize() + if err != nil { + c.harness.T.Fatalf("failed to initialize lsp: %v", err) + } + c.isInitialized = true + cleanups = append(cleanups, &lntest.Cleanup{ + Name: fmt.Sprintf("%s: lsp base", c.lspBase.name), + Fn: c.lspBase.Stop, + }) + } + + c.lightningNode.Start() + cleanups = append(cleanups, &lntest.Cleanup{ + Name: fmt.Sprintf("%s: lightning node", c.lspBase.name), + Fn: c.lightningNode.Stop, + }) + + cmd := exec.CommandContext(c.harness.Ctx, c.lspBase.scriptFilePath) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + logFile, err := os.Create(c.logFilePath) + if err != nil { + lntest.PerformCleanup(cleanups) + c.harness.T.Fatalf("failed create lsp logfile: %v", err) + } + cleanups = append(cleanups, &lntest.Cleanup{ + Name: fmt.Sprintf("%s: logfile", c.lspBase.name), + Fn: logFile.Close, + }) + + cmd.Stdout = logFile + cmd.Stderr = logFile + + log.Printf("%s: starting lspd %s", c.lspBase.name, c.lspBase.scriptFilePath) + err = cmd.Start() + if err != nil { + lntest.PerformCleanup(cleanups) + c.harness.T.Fatalf("failed to start lspd: %v", err) + } + cleanups = append(cleanups, &lntest.Cleanup{ + Name: fmt.Sprintf("%s: cmd", c.lspBase.name), + Fn: func() error { + proc := cmd.Process + if proc == nil { + return nil + } + + syscall.Kill(-proc.Pid, syscall.SIGINT) + + log.Printf("About to wait for lspd to exit") + status, err := proc.Wait() + if err != nil { + log.Printf("waiting for lspd process error: %v, status: %v", err, status) + } + err = cmd.Wait() + if err != nil { + log.Printf("waiting for lspd cmd error: %v", err) + } + + return nil + }, + }) + + conn, err := grpc.Dial( + c.lspBase.grpcAddress, + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithPerRPCCredentials(&token{token: "hello"}), + ) + if err != nil { + lntest.PerformCleanup(cleanups) + c.harness.T.Fatalf("failed to create grpc connection: %v", err) + } + cleanups = append(cleanups, &lntest.Cleanup{ + Name: fmt.Sprintf("%s: grpc conn", c.lspBase.name), + Fn: conn.Close, + }) + + client := lspd.NewChannelOpenerClient(conn) + notif := notifications.NewNotificationsClient(conn) + c.runtime = &clnLspNodeRuntime{ + logFile: logFile, + cmd: cmd, + rpc: client, + notificationRpc: notif, + cleanups: cleanups, + } +} + +func (c *ClnLspNode) Stop() error { + c.mtx.Lock() + defer c.mtx.Unlock() + + if c.runtime == nil { + return nil + } + + lntest.PerformCleanup(c.runtime.cleanups) + c.runtime = nil + return nil +} + +func (c *ClnLspNode) Harness() *lntest.TestHarness { + return c.harness +} + +func (c *ClnLspNode) PublicKey() *btcec.PublicKey { + return c.lspBase.pubkey +} + +func (c *ClnLspNode) EciesPublicKey() *ecies.PublicKey { + return c.lspBase.eciesPubkey +} + +func (c *ClnLspNode) Rpc() lspd.ChannelOpenerClient { + return c.runtime.rpc +} + +func (c *ClnLspNode) NotificationsRpc() notifications.NotificationsClient { + return c.runtime.notificationRpc +} + +func (l *ClnLspNode) NodeId() []byte { + return l.lightningNode.NodeId() +} + +func (l *ClnLspNode) LightningNode() lntest.LightningNode { + return l.lightningNode +} + +func (l *ClnLspNode) PostgresBackend() *PostgresContainer { + return l.lspBase.postgresBackend +} diff --git a/itest/cltv_test.go b/itest/cltv_test.go new file mode 100644 index 0000000..bbe0f5c --- /dev/null +++ b/itest/cltv_test.go @@ -0,0 +1,58 @@ +package itest + +import ( + "log" + "time" + + "github.com/breez/lntest" + lspd "github.com/breez/lspd/rpc" + "github.com/stretchr/testify/assert" +) + +func testInvalidCltv(p *testParams) { + alice := lntest.NewClnNode(p.h, p.m, "Alice") + alice.Start() + alice.Fund(10000000) + p.lsp.LightningNode().Fund(10000000) + + log.Print("Opening channel between Alice and the lsp") + channel := alice.OpenChannel(p.lsp.LightningNode(), &lntest.OpenChannelOptions{ + AmountSat: publicChanAmount, + }) + channelId := alice.WaitForChannelReady(channel) + + log.Printf("Adding bob's invoices") + outerAmountMsat := uint64(2100000) + innerAmountMsat := calculateInnerAmountMsat(p.lsp, outerAmountMsat, nil) + description := "Please pay me" + innerInvoice, outerInvoice := GenerateInvoices(p.BreezClient(), + generateInvoicesRequest{ + innerAmountMsat: innerAmountMsat, + outerAmountMsat: outerAmountMsat, + description: description, + lsp: p.lsp, + }) + + log.Print("Connecting bob to lspd") + p.BreezClient().Node().ConnectPeer(p.lsp.LightningNode()) + + log.Printf("Registering payment with lsp") + RegisterPayment(p.lsp, &lspd.PaymentInformation{ + PaymentHash: innerInvoice.paymentHash, + PaymentSecret: innerInvoice.paymentSecret, + Destination: p.BreezClient().Node().NodeId(), + IncomingAmountMsat: int64(outerAmountMsat), + OutgoingAmountMsat: int64(innerAmountMsat), + }, false) + + // TODO: Fix race waiting for htlc interceptor. + log.Printf("Waiting %v to allow htlc interceptor to activate.", htlcInterceptorDelay) + <-time.After(htlcInterceptorDelay) + log.Printf("Alice paying") + route := constructRoute(p.lsp.LightningNode(), p.BreezClient().Node(), channelId, lntest.NewShortChanIDFromString("1x0x0"), outerAmountMsat) + + // Decrement the delay in the first hop, so the cltv delta will become 143 (too little) + route.Hops[0].Delay-- + _, err := alice.PayViaRoute(outerAmountMsat, outerInvoice.paymentHash, outerInvoice.paymentSecret, route) + assert.Contains(p.t, err.Error(), "WIRE_TEMPORARY_CHANNEL_FAILURE") +} diff --git a/itest/config_test.go b/itest/config_test.go new file mode 100644 index 0000000..cf69ea8 --- /dev/null +++ b/itest/config_test.go @@ -0,0 +1,42 @@ +package itest + +import ( + "encoding/hex" + "log" + "testing" + "time" + + "github.com/breez/lntest" + lspd "github.com/breez/lspd/rpc" + "github.com/stretchr/testify/assert" +) + +func TestConfigParameters(t *testing.T) { + deadline, _ := t.Deadline() + h := lntest.NewTestHarness(t, deadline) + defer h.TearDown() + + m := lntest.NewMiner(h) + m.Start() + + mem := NewMempoolApi(h) + mem.Start() + + lsp := NewClnLspdNode(h, m, mem, "lsp", nil) + lsp.Start() + + log.Printf("Waiting %v to allow lsp server to activate.", htlcInterceptorDelay) + <-time.After(htlcInterceptorDelay) + + info, err := lsp.Rpc().ChannelInformation( + h.Ctx, + &lspd.ChannelInformationRequest{}, + ) + + if err != nil { + t.Fatalf("failed to get channelinformation: %v", err) + } + + assert.Equal(t, hex.EncodeToString(lsp.LightningNode().NodeId()), info.Pubkey) + assert.Equal(t, "lsp", info.Name) +} diff --git a/itest/dynamic_fee_test.go b/itest/dynamic_fee_test.go new file mode 100644 index 0000000..7098add --- /dev/null +++ b/itest/dynamic_fee_test.go @@ -0,0 +1,141 @@ +package itest + +import ( + "log" + "time" + + "github.com/breez/lntest" + lspd "github.com/breez/lspd/rpc" + "github.com/stretchr/testify/assert" +) + +func testDynamicFeeFlow(p *testParams) { + alice := lntest.NewClnNode(p.h, p.m, "Alice") + alice.Start() + alice.Fund(10000000) + p.lsp.LightningNode().Fund(10000000) + + log.Print("Opening channel between Alice and the lsp") + channel := alice.OpenChannel(p.lsp.LightningNode(), &lntest.OpenChannelOptions{ + AmountSat: publicChanAmount, + }) + channelId := alice.WaitForChannelReady(channel) + + log.Printf("Getting channel information") + SetFeeParams(p.lsp, []*FeeParamSetting{ + { + Validity: time.Second * 3600, + MinMsat: 3000000, + Proportional: 1000, + }, + }, + ) + info := ChannelInformation(p.lsp) + assert.Len(p.t, info.OpeningFeeParamsMenu, 1) + params := info.OpeningFeeParamsMenu[0] + assert.Equal(p.t, uint64(3000000), params.MinMsat) + + log.Printf("opening_fee_params: %+v", params) + log.Printf("Adding bob's invoices") + outerAmountMsat := uint64(4200000) + innerAmountMsat := calculateInnerAmountMsat(p.lsp, outerAmountMsat, params) + description := "Please pay me" + innerInvoice, outerInvoice := GenerateInvoices(p.BreezClient(), + generateInvoicesRequest{ + innerAmountMsat: innerAmountMsat, + outerAmountMsat: outerAmountMsat, + description: description, + lsp: p.lsp, + }) + + p.BreezClient().SetHtlcAcceptor(innerAmountMsat) + log.Print("Connecting bob to lspd") + p.BreezClient().Node().ConnectPeer(p.lsp.LightningNode()) + + log.Printf("Testing some bad registrations") + err := RegisterPayment(p.lsp, &lspd.PaymentInformation{ + PaymentHash: innerInvoice.paymentHash, + PaymentSecret: innerInvoice.paymentSecret, + Destination: p.BreezClient().Node().NodeId(), + IncomingAmountMsat: int64(outerAmountMsat), + OutgoingAmountMsat: int64(innerAmountMsat), + OpeningFeeParams: &lspd.OpeningFeeParams{ + // modify minmsat + MinMsat: params.MinMsat + 1, + Proportional: params.Proportional, + ValidUntil: params.ValidUntil, + MaxIdleTime: params.MaxIdleTime, + MaxClientToSelfDelay: params.MaxClientToSelfDelay, + Promise: params.Promise, + }, + }, true) + assert.Contains(p.t, err.Error(), "invalid opening_fee_params") + + err = RegisterPayment(p.lsp, &lspd.PaymentInformation{ + PaymentHash: innerInvoice.paymentHash, + PaymentSecret: innerInvoice.paymentSecret, + Destination: p.BreezClient().Node().NodeId(), + IncomingAmountMsat: int64(outerAmountMsat), + OutgoingAmountMsat: int64(innerAmountMsat), + OpeningFeeParams: &lspd.OpeningFeeParams{ + MinMsat: params.MinMsat, + Proportional: params.Proportional, + ValidUntil: params.ValidUntil, + MaxIdleTime: params.MaxIdleTime, + MaxClientToSelfDelay: params.MaxClientToSelfDelay, + // Modify promise + Promise: params.Promise + "aa", + }, + }, true) + assert.Contains(p.t, err.Error(), "invalid opening_fee_params") + + err = RegisterPayment(p.lsp, &lspd.PaymentInformation{ + PaymentHash: innerInvoice.paymentHash, + PaymentSecret: innerInvoice.paymentSecret, + Destination: p.BreezClient().Node().NodeId(), + // Fee too low + IncomingAmountMsat: int64(2999999), + OutgoingAmountMsat: int64(0), + OpeningFeeParams: params, + }, true) + assert.Contains(p.t, err.Error(), "not enough fees") + + err = RegisterPayment(p.lsp, &lspd.PaymentInformation{ + PaymentHash: innerInvoice.paymentHash, + PaymentSecret: innerInvoice.paymentSecret, + Destination: p.BreezClient().Node().NodeId(), + IncomingAmountMsat: int64(outerAmountMsat), + OutgoingAmountMsat: int64(innerAmountMsat + 1), + OpeningFeeParams: params, + }, true) + assert.Contains(p.t, err.Error(), "not enough fees") + + // Now register the payment for real + log.Printf("Registering payment with lsp") + RegisterPayment(p.lsp, &lspd.PaymentInformation{ + PaymentHash: innerInvoice.paymentHash, + PaymentSecret: innerInvoice.paymentSecret, + Destination: p.BreezClient().Node().NodeId(), + IncomingAmountMsat: int64(outerAmountMsat), + OutgoingAmountMsat: int64(innerAmountMsat), + OpeningFeeParams: info.OpeningFeeParamsMenu[0], + }, false) + + // TODO: Fix race waiting for htlc interceptor. + log.Printf("Waiting %v to allow htlc interceptor to activate.", htlcInterceptorDelay) + <-time.After(htlcInterceptorDelay) + log.Printf("Alice paying") + route := constructRoute(p.lsp.LightningNode(), p.BreezClient().Node(), channelId, lntest.NewShortChanIDFromString("1x0x0"), outerAmountMsat) + payResp, err := alice.PayViaRoute(outerAmountMsat, outerInvoice.paymentHash, outerInvoice.paymentSecret, route) + lntest.CheckError(p.t, err) + bobInvoice := p.BreezClient().Node().GetInvoice(payResp.PaymentHash) + + assert.Equal(p.t, payResp.PaymentPreimage, bobInvoice.PaymentPreimage) + assert.Equal(p.t, innerAmountMsat, bobInvoice.AmountReceivedMsat) + + // Make sure capacity is correct + chans := p.BreezClient().Node().GetChannels() + assert.Equal(p.t, 1, len(chans)) + c := chans[0] + AssertChannelCapacity(p.t, outerAmountMsat, c.CapacityMsat) +} diff --git a/itest/intercept_zero_conf_test.go b/itest/intercept_zero_conf_test.go new file mode 100644 index 0000000..bc8b82d --- /dev/null +++ b/itest/intercept_zero_conf_test.go @@ -0,0 +1,147 @@ +package itest + +import ( + "log" + "time" + + "github.com/breez/lntest" + lspd "github.com/breez/lspd/rpc" + "github.com/stretchr/testify/assert" +) + +var htlcInterceptorDelay = time.Second * 7 + +func testOpenZeroConfChannelOnReceive(p *testParams) { + alice := lntest.NewClnNode(p.h, p.m, "Alice") + alice.Start() + alice.Fund(10000000) + p.lsp.LightningNode().Fund(10000000) + + log.Print("Opening channel between Alice and the lsp") + channel := alice.OpenChannel(p.lsp.LightningNode(), &lntest.OpenChannelOptions{ + AmountSat: publicChanAmount, + }) + alice.WaitForChannelReady(channel) + + log.Printf("Adding bob's invoices") + outerAmountMsat := uint64(2100000) + innerAmountMsat := calculateInnerAmountMsat(p.lsp, outerAmountMsat, nil) + description := "Please pay me" + innerInvoice, outerInvoice := GenerateInvoices(p.BreezClient(), + generateInvoicesRequest{ + innerAmountMsat: innerAmountMsat, + outerAmountMsat: outerAmountMsat, + description: description, + lsp: p.lsp, + }) + p.BreezClient().SetHtlcAcceptor(innerAmountMsat) + + log.Print("Connecting bob to lspd") + p.BreezClient().Node().ConnectPeer(p.lsp.LightningNode()) + + log.Printf("Registering payment with lsp") + RegisterPayment(p.lsp, &lspd.PaymentInformation{ + PaymentHash: innerInvoice.paymentHash, + PaymentSecret: innerInvoice.paymentSecret, + Destination: p.BreezClient().Node().NodeId(), + IncomingAmountMsat: int64(outerAmountMsat), + OutgoingAmountMsat: int64(innerAmountMsat), + }, false) + + // TODO: Fix race waiting for htlc interceptor. + log.Printf("Waiting %v to allow htlc interceptor to activate.", htlcInterceptorDelay) + <-time.After(htlcInterceptorDelay) + log.Printf("Alice paying") + payResp := alice.Pay(outerInvoice.bolt11) + bobInvoice := p.BreezClient().Node().GetInvoice(payResp.PaymentHash) + + assert.Equal(p.t, payResp.PaymentPreimage, bobInvoice.PaymentPreimage) + assert.Equal(p.t, innerAmountMsat, bobInvoice.AmountReceivedMsat) + + // Make sure capacity is correct + chans := p.BreezClient().Node().GetChannels() + assert.Equal(p.t, 1, len(chans)) + c := chans[0] + AssertChannelCapacity(p.t, outerAmountMsat, c.CapacityMsat) +} + +func testOpenZeroConfSingleHtlc(p *testParams) { + alice := lntest.NewClnNode(p.h, p.m, "Alice") + alice.Start() + alice.Fund(10000000) + p.lsp.LightningNode().Fund(10000000) + + log.Print("Opening channel between Alice and the lsp") + channel := alice.OpenChannel(p.lsp.LightningNode(), &lntest.OpenChannelOptions{ + AmountSat: publicChanAmount, + }) + channelId := alice.WaitForChannelReady(channel) + + log.Printf("Adding bob's invoices") + outerAmountMsat := uint64(2100000) + innerAmountMsat := calculateInnerAmountMsat(p.lsp, outerAmountMsat, nil) + description := "Please pay me" + innerInvoice, outerInvoice := GenerateInvoices(p.BreezClient(), + generateInvoicesRequest{ + innerAmountMsat: innerAmountMsat, + outerAmountMsat: outerAmountMsat, + description: description, + lsp: p.lsp, + }) + + p.BreezClient().SetHtlcAcceptor(innerAmountMsat) + log.Print("Connecting bob to lspd") + p.BreezClient().Node().ConnectPeer(p.lsp.LightningNode()) + + log.Printf("Registering payment with lsp") + RegisterPayment(p.lsp, &lspd.PaymentInformation{ + PaymentHash: innerInvoice.paymentHash, + PaymentSecret: innerInvoice.paymentSecret, + Destination: p.BreezClient().Node().NodeId(), + IncomingAmountMsat: int64(outerAmountMsat), + OutgoingAmountMsat: int64(innerAmountMsat), + }, false) + + // TODO: Fix race waiting for htlc interceptor. + log.Printf("Waiting %v to allow htlc interceptor to activate.", htlcInterceptorDelay) + <-time.After(htlcInterceptorDelay) + log.Printf("Alice paying") + route := constructRoute(p.lsp.LightningNode(), p.BreezClient().Node(), channelId, lntest.NewShortChanIDFromString("1x0x0"), outerAmountMsat) + payResp, err := alice.PayViaRoute(outerAmountMsat, outerInvoice.paymentHash, outerInvoice.paymentSecret, route) + lntest.CheckError(p.t, err) + bobInvoice := p.BreezClient().Node().GetInvoice(payResp.PaymentHash) + + assert.Equal(p.t, payResp.PaymentPreimage, bobInvoice.PaymentPreimage) + assert.Equal(p.t, innerAmountMsat, bobInvoice.AmountReceivedMsat) + + // Make sure capacity is correct + chans := p.BreezClient().Node().GetChannels() + assert.Equal(p.t, 1, len(chans)) + c := chans[0] + AssertChannelCapacity(p.t, outerAmountMsat, c.CapacityMsat) +} + +func constructRoute( + lsp lntest.LightningNode, + bob lntest.LightningNode, + aliceLspChannel lntest.ShortChannelID, + lspBobChannel lntest.ShortChannelID, + amountMsat uint64, +) *lntest.Route { + return &lntest.Route{ + Hops: []*lntest.Hop{ + { + Id: lsp.NodeId(), + Channel: aliceLspChannel, + AmountMsat: amountMsat + uint64(lspBaseFeeMsat) + (amountMsat * uint64(lspFeeRatePpm) / 1000000), + Delay: 144 + lspCltvDelta, + }, + { + Id: bob.NodeId(), + Channel: lspBobChannel, + AmountMsat: amountMsat, + Delay: 144, + }, + }, + } +} diff --git a/itest/lnd_breez_client.go b/itest/lnd_breez_client.go new file mode 100644 index 0000000..c95febd --- /dev/null +++ b/itest/lnd_breez_client.go @@ -0,0 +1,116 @@ +package itest + +import ( + "context" + "flag" + "sync" + + "github.com/breez/lntest" + "github.com/breez/lntest/lnd" + "github.com/lightningnetwork/lnd/lnwire" +) + +var lndMobileExecutable = flag.String( + "lndmobileexec", "", "full path to lnd mobile binary", +) + +type lndBreezClient struct { + name string + harness *lntest.TestHarness + node *lntest.LndNode + cancel context.CancelFunc + mtx sync.Mutex +} + +func newLndBreezClient(h *lntest.TestHarness, m *lntest.Miner, name string) BreezClient { + lnd := lntest.NewLndNodeFromBinary(h, m, name, *lndMobileExecutable, + "--protocol.zero-conf", + "--protocol.option-scid-alias", + "--bitcoin.defaultchanconfs=0", + ) + + c := &lndBreezClient{ + name: name, + harness: h, + node: lnd, + } + h.AddStoppable(c) + return c +} + +func (c *lndBreezClient) Name() string { + return c.name +} + +func (c *lndBreezClient) Harness() *lntest.TestHarness { + return c.harness +} + +func (c *lndBreezClient) Node() lntest.LightningNode { + return c.node +} + +func (c *lndBreezClient) Start() { + c.mtx.Lock() + defer c.mtx.Unlock() + + if c.node.IsStarted() { + return + } + + c.node.Start() + + ctx, cancel := context.WithCancel(c.harness.Ctx) + c.cancel = cancel + go c.startChannelAcceptor(ctx) +} + +func (c *lndBreezClient) Stop() error { + c.mtx.Lock() + defer c.mtx.Unlock() + + // Stop the channel acceptor + if c.cancel != nil { + c.cancel() + c.cancel = nil + } + + return c.node.Stop() +} + +func (c *lndBreezClient) ResetHtlcAcceptor() { + +} + +func (c *lndBreezClient) SetHtlcAcceptor(totalMsat uint64) { + // No need for a htlc acceptor in the LND breez client +} + +func (c *lndBreezClient) startChannelAcceptor(ctx context.Context) error { + client, err := c.node.LightningClient().ChannelAcceptor(ctx) + if err != nil { + c.harness.T.Fatalf("%s: failed to create channel acceptor: %v", c.name, err) + } + + for { + request, err := client.Recv() + if err != nil { + return err + } + + private := request.ChannelFlags&uint32(lnwire.FFAnnounceChannel) == 0 + resp := &lnd.ChannelAcceptResponse{ + PendingChanId: request.PendingChanId, + Accept: private, + } + if request.WantsZeroConf { + resp.MinAcceptDepth = 0 + resp.ZeroConf = true + } + + err = client.Send(resp) + if err != nil { + c.harness.T.Fatalf("%s: failed to send acceptor response: %v", c.name, err) + } + } +} diff --git a/itest/lnd_lspd_node.go b/itest/lnd_lspd_node.go new file mode 100644 index 0000000..745b14b --- /dev/null +++ b/itest/lnd_lspd_node.go @@ -0,0 +1,251 @@ +package itest + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "syscall" + + "github.com/breez/lntest" + "github.com/breez/lspd/config" + "github.com/breez/lspd/notifications" + lspd "github.com/breez/lspd/rpc" + "github.com/btcsuite/btcd/btcec/v2" + ecies "github.com/ecies/go/v2" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +type LndLspNode struct { + harness *lntest.TestHarness + lightningNode *lntest.LndNode + logFilePath string + isInitialized bool + lspBase *lspBase + runtime *lndLspNodeRuntime + mtx sync.Mutex +} + +type lndLspNodeRuntime struct { + logFile *os.File + cmd *exec.Cmd + rpc lspd.ChannelOpenerClient + notificationRpc notifications.NotificationsClient + cleanups []*lntest.Cleanup +} + +func NewLndLspdNode(h *lntest.TestHarness, m *lntest.Miner, mem *mempoolApi, name string, nodeConfig *config.NodeConfig) LspNode { + args := []string{ + "--protocol.zero-conf", + "--protocol.option-scid-alias", + "--requireinterceptor", + "--bitcoin.defaultchanconfs=0", + fmt.Sprintf("--bitcoin.chanreservescript=\"0 if (chanAmt != %d) else chanAmt/100\"", publicChanAmount), + fmt.Sprintf("--bitcoin.basefee=%d", lspBaseFeeMsat), + fmt.Sprintf("--bitcoin.feerate=%d", lspFeeRatePpm), + fmt.Sprintf("--bitcoin.timelockdelta=%d", lspCltvDelta), + } + + lightningNode := lntest.NewLndNode(h, m, name, args...) + lnd := &config.LndConfig{ + Address: lightningNode.GrpcHost(), + Cert: string(lightningNode.TlsCert()), + Macaroon: hex.EncodeToString(lightningNode.Macaroon()), + } + lspBase, err := newLspd(h, mem, name, nodeConfig, lnd, nil) + if err != nil { + h.T.Fatalf("failed to initialize lspd") + } + scriptDir := filepath.Dir(lspBase.scriptFilePath) + logFilePath := filepath.Join(scriptDir, "lspd.log") + h.RegisterLogfile(logFilePath, fmt.Sprintf("lspd-%s", name)) + + lspNode := &LndLspNode{ + harness: h, + lightningNode: lightningNode, + logFilePath: logFilePath, + lspBase: lspBase, + } + + h.AddStoppable(lspNode) + return lspNode +} + +func (c *LndLspNode) Start() { + c.mtx.Lock() + defer c.mtx.Unlock() + + var cleanups []*lntest.Cleanup + wasInitialized := c.isInitialized + if !c.isInitialized { + err := c.lspBase.Initialize() + if err != nil { + c.harness.T.Fatalf("failed to initialize lsp: %v", err) + } + c.isInitialized = true + cleanups = append(cleanups, &lntest.Cleanup{ + Name: fmt.Sprintf("%s: lsp base", c.lspBase.name), + Fn: c.lspBase.Stop, + }) + } + + c.lightningNode.Start() + cleanups = append(cleanups, &lntest.Cleanup{ + Name: fmt.Sprintf("%s: lsp lightning node", c.lspBase.name), + Fn: c.lightningNode.Stop, + }) + + if !wasInitialized { + scriptFile, err := os.ReadFile(c.lspBase.scriptFilePath) + if err != nil { + lntest.PerformCleanup(cleanups) + c.harness.T.Fatalf("failed to open scriptfile '%s': %v", c.lspBase.scriptFilePath, err) + } + + err = os.Remove(c.lspBase.scriptFilePath) + if err != nil { + lntest.PerformCleanup(cleanups) + c.harness.T.Fatalf("failed to remove scriptfile '%s': %v", c.lspBase.scriptFilePath, err) + } + + split := strings.Split(string(scriptFile), "\n") + for i, s := range split { + if strings.HasPrefix(s, "export NODES") { + j, _ := json.Marshal(map[string]string{ + "address": c.lightningNode.GrpcHost(), + "cert": string(c.lightningNode.TlsCert()), + "macaroon": hex.EncodeToString(c.lightningNode.Macaroon())}) + ext := fmt.Sprintf(`"lnd": %s}]'`, string(j)) + start, _, _ := strings.Cut(s, `"lnd"`) + + split[i] = start + ext + } + } + newContent := strings.Join(split, "\n") + err = os.WriteFile(c.lspBase.scriptFilePath, []byte(newContent), 0755) + if err != nil { + lntest.PerformCleanup(cleanups) + c.harness.T.Fatalf("failed to rewrite scriptfile '%s': %v", c.lspBase.scriptFilePath, err) + } + } + + cmd := exec.CommandContext(c.harness.Ctx, c.lspBase.scriptFilePath) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + logFile, err := os.Create(c.logFilePath) + if err != nil { + lntest.PerformCleanup(cleanups) + c.harness.T.Fatalf("failed create lsp logfile: %v", err) + } + cleanups = append(cleanups, &lntest.Cleanup{ + Name: fmt.Sprintf("%s: logfile", c.lspBase.name), + Fn: logFile.Close, + }) + + cmd.Stdout = logFile + cmd.Stderr = logFile + + log.Printf("%s: starting lspd %s", c.lspBase.name, c.lspBase.scriptFilePath) + err = cmd.Start() + if err != nil { + lntest.PerformCleanup(cleanups) + c.harness.T.Fatalf("failed to start lspd: %v", err) + } + cleanups = append(cleanups, &lntest.Cleanup{ + Name: fmt.Sprintf("%s: cmd", c.lspBase.name), + Fn: func() error { + proc := cmd.Process + if proc == nil { + return nil + } + + syscall.Kill(-proc.Pid, syscall.SIGINT) + + log.Printf("About to wait for lspd to exit") + status, err := proc.Wait() + if err != nil { + log.Printf("waiting for lspd process error: %v, status: %v", err, status) + } + err = cmd.Wait() + if err != nil { + log.Printf("waiting for lspd cmd error: %v", err) + } + + return nil + }, + }) + + conn, err := grpc.Dial( + c.lspBase.grpcAddress, + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithPerRPCCredentials(&token{token: "hello"}), + ) + if err != nil { + lntest.PerformCleanup(cleanups) + c.harness.T.Fatalf("failed to create grpc connection: %v", err) + } + cleanups = append(cleanups, &lntest.Cleanup{ + Name: fmt.Sprintf("%s: grpc conn", c.lspBase.name), + Fn: conn.Close, + }) + + client := lspd.NewChannelOpenerClient(conn) + notifyClient := notifications.NewNotificationsClient(conn) + c.runtime = &lndLspNodeRuntime{ + logFile: logFile, + cmd: cmd, + rpc: client, + notificationRpc: notifyClient, + cleanups: cleanups, + } +} + +func (c *LndLspNode) Stop() error { + c.mtx.Lock() + defer c.mtx.Unlock() + + if c.runtime == nil { + return nil + } + + lntest.PerformCleanup(c.runtime.cleanups) + c.runtime = nil + return nil +} + +func (c *LndLspNode) Harness() *lntest.TestHarness { + return c.harness +} + +func (c *LndLspNode) PublicKey() *btcec.PublicKey { + return c.lspBase.pubkey +} + +func (c *LndLspNode) EciesPublicKey() *ecies.PublicKey { + return c.lspBase.eciesPubkey +} + +func (c *LndLspNode) Rpc() lspd.ChannelOpenerClient { + return c.runtime.rpc +} + +func (c *LndLspNode) NotificationsRpc() notifications.NotificationsClient { + return c.runtime.notificationRpc +} + +func (l *LndLspNode) NodeId() []byte { + return l.lightningNode.NodeId() +} + +func (l *LndLspNode) LightningNode() lntest.LightningNode { + return l.lightningNode +} + +func (l *LndLspNode) PostgresBackend() *PostgresContainer { + return l.lspBase.postgresBackend +} diff --git a/itest/lspd_node.go b/itest/lspd_node.go new file mode 100644 index 0000000..cdbb9e5 --- /dev/null +++ b/itest/lspd_node.go @@ -0,0 +1,366 @@ +package itest + +import ( + "bufio" + "context" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/breez/lntest" + "github.com/breez/lspd/config" + "github.com/breez/lspd/notifications" + lspd "github.com/breez/lspd/rpc" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/decred/dcrd/dcrec/secp256k1/v4" + ecies "github.com/ecies/go/v2" + "google.golang.org/protobuf/proto" + "github.com/jackc/pgx/v4/pgxpool" +) + +var ( + lspdExecutable = flag.String( + "lspdexec", "", "full path to lpsd plugin binary", + ) + lspdMigrationsDir = flag.String( + "lspdmigrationsdir", "", "full path to lspd sql migrations directory", + ) +) + +var ( + lspBaseFeeMsat uint32 = 1000 + lspFeeRatePpm uint32 = 1 + lspCltvDelta uint16 = 144 +) + +type LspNode interface { + Start() + Stop() error + Harness() *lntest.TestHarness + PublicKey() *btcec.PublicKey + EciesPublicKey() *ecies.PublicKey + Rpc() lspd.ChannelOpenerClient + NotificationsRpc() notifications.NotificationsClient + NodeId() []byte + LightningNode() lntest.LightningNode + PostgresBackend() *PostgresContainer +} + +type lspBase struct { + harness *lntest.TestHarness + name string + binary string + env []string + scriptFilePath string + grpcAddress string + pubkey *secp256k1.PublicKey + eciesPubkey *ecies.PublicKey + postgresBackend *PostgresContainer +} + +func newLspd(h *lntest.TestHarness, mem *mempoolApi, name string, nodeConfig *config.NodeConfig, lnd *config.LndConfig, cln *config.ClnConfig, envExt ...string) (*lspBase, error) { + scriptDir := h.GetDirectory(fmt.Sprintf("lspd-%s", name)) + log.Printf("%s: Creating LSPD in dir %s", name, scriptDir) + + pgLogfile := filepath.Join(scriptDir, "postgres.log") + h.RegisterLogfile(pgLogfile, fmt.Sprintf("%s-postgres", name)) + postgresBackend, err := NewPostgresContainer(pgLogfile) + if err != nil { + return nil, err + } + + lspdBinary, err := getLspdBinary() + if err != nil { + return nil, err + } + + lspdPort, err := lntest.GetPort() + if err != nil { + return nil, err + } + + lspdPrivateKeyBytes, err := GenerateRandomBytes(32) + if err != nil { + return nil, err + } + + _, publ := btcec.PrivKeyFromBytes(lspdPrivateKeyBytes) + eciesPubl := ecies.NewPrivateKeyFromBytes(lspdPrivateKeyBytes).PublicKey + host := "localhost" + grpcAddress := fmt.Sprintf("%s:%d", host, lspdPort) + minConfs := uint32(1) + conf := &config.NodeConfig{ + Name: name, + LspdPrivateKey: hex.EncodeToString(lspdPrivateKeyBytes), + Tokens: []string{"hello"}, + Host: "host:port", + PublicChannelAmount: 1000183, + ChannelAmount: 100000, + ChannelPrivate: false, + TargetConf: 6, + MinConfs: &minConfs, + MinHtlcMsat: 600, + BaseFeeMsat: 1000, + FeeRate: 0.000001, + TimeLockDelta: 144, + ChannelFeePermyriad: 40, + ChannelMinimumFeeMsat: 2000000, + AdditionalChannelCapacity: 100000, + MaxInactiveDuration: 3888000, + Lnd: lnd, + Cln: cln, + } + + if nodeConfig != nil { + if nodeConfig.MinConfs != nil { + conf.MinConfs = nodeConfig.MinConfs + } + } + + log.Printf("%s: node config: %+v", name, conf) + confJson, _ := json.Marshal(conf) + nodes := fmt.Sprintf(`NODES='[%s]'`, string(confJson)) + env := []string{ + nodes, + fmt.Sprintf("DATABASE_URL=%s", postgresBackend.ConnectionString()), + fmt.Sprintf("LISTEN_ADDRESS=%s", grpcAddress), + fmt.Sprintf("MEMPOOL_API_BASE_URL=%s", mem.Address()), + "MEMPOOL_PRIORITY=economy", + } + + env = append(env, envExt...) + + scriptFilePath := filepath.Join(scriptDir, "start-lspd.sh") + + l := &lspBase{ + harness: h, + name: name, + env: env, + binary: lspdBinary, + scriptFilePath: scriptFilePath, + grpcAddress: grpcAddress, + pubkey: publ, + eciesPubkey: eciesPubl, + postgresBackend: postgresBackend, + } + h.AddStoppable(l) + h.AddCleanable(l) + return l, nil +} + +func (l *lspBase) Stop() error { + return l.postgresBackend.Stop(context.Background()) +} + +func (l *lspBase) Cleanup() error { + return l.postgresBackend.Cleanup(context.Background()) +} + +func (l *lspBase) Initialize() error { + var cleanups []*lntest.Cleanup + migrationsDir, err := getMigrationsDir() + if err != nil { + return err + } + + err = l.postgresBackend.Start(l.harness.Ctx) + if err != nil { + return err + } + + cleanups = append(cleanups, &lntest.Cleanup{ + Name: fmt.Sprintf("%s: postgres container", l.name), + Fn: func() error { + return l.postgresBackend.Stop(context.Background()) + }, + }) + err = l.postgresBackend.RunMigrations(l.harness.Ctx, migrationsDir) + if err != nil { + lntest.PerformCleanup(cleanups) + return err + } + + pgxPool, err := pgxpool.Connect(l.harness.Ctx, l.postgresBackend.ConnectionString()) + if err != nil { + lntest.PerformCleanup(cleanups) + return fmt.Errorf("failed to connect to postgres: %w", err) + } + defer pgxPool.Close() + + _, err = pgxPool.Exec( + l.harness.Ctx, + `DELETE FROM new_channel_params`, + ) + if err != nil { + lntest.PerformCleanup(cleanups) + return fmt.Errorf("failed to delete new_channel_params: %w", err) + } + + _, err = pgxPool.Exec( + l.harness.Ctx, + `INSERT INTO new_channel_params (validity, params, token) + VALUES + (3600, '{"min_msat": "1000000", "proportional": 7500, "max_idle_time": 4320, "max_client_to_self_delay": 432}', 'hello'), + (259200, '{"min_msat": "1100000", "proportional": 7500, "max_idle_time": 4320, "max_client_to_self_delay": 432}', 'hello');`, + ) + if err != nil { + lntest.PerformCleanup(cleanups) + return fmt.Errorf("failed to insert new_channel_params: %w", err) + } + + log.Printf("%s: Creating lspd startup script at %s", l.name, l.scriptFilePath) + scriptFile, err := os.OpenFile(l.scriptFilePath, os.O_CREATE|os.O_WRONLY, 0755) + if err != nil { + lntest.PerformCleanup(cleanups) + return err + } + + defer scriptFile.Close() + writer := bufio.NewWriter(scriptFile) + _, err = writer.WriteString("#!/bin/bash\n") + if err != nil { + lntest.PerformCleanup(cleanups) + return err + } + + for _, str := range l.env { + _, err = writer.WriteString("export " + str + "\n") + if err != nil { + lntest.PerformCleanup(cleanups) + return err + } + } + + _, err = writer.WriteString(l.binary + "\n") + if err != nil { + lntest.PerformCleanup(cleanups) + return err + } + + err = writer.Flush() + if err != nil { + lntest.PerformCleanup(cleanups) + return err + } + + return nil +} + +func ChannelInformation(l LspNode) *lspd.ChannelInformationReply { + info, err := l.Rpc().ChannelInformation( + l.Harness().Ctx, + &lspd.ChannelInformationRequest{}, + ) + if err != nil { + l.Harness().T.Fatalf("Failed to get ChannelInformation: %v", err) + } + + return info +} + +func RegisterPayment(l LspNode, paymentInfo *lspd.PaymentInformation, continueOnError bool) error { + serialized, err := proto.Marshal(paymentInfo) + lntest.CheckError(l.Harness().T, err) + + encrypted, err := ecies.Encrypt(l.EciesPublicKey(), serialized) + lntest.CheckError(l.Harness().T, err) + + log.Printf("Registering payment") + _, err = l.Rpc().RegisterPayment( + l.Harness().Ctx, + &lspd.RegisterPaymentRequest{ + Blob: encrypted, + }, + ) + + if !continueOnError { + lntest.CheckError(l.Harness().T, err) + } + + return err +} + +type FeeParamSetting struct { + Validity time.Duration + MinMsat uint64 + Proportional uint32 +} + +func SetFeeParams(l LspNode, settings []*FeeParamSetting) error { + pgxPool, err := pgxpool.Connect(l.Harness().Ctx, l.PostgresBackend().ConnectionString()) + if err != nil { + return fmt.Errorf("failed to connect to postgres: %w", err) + } + defer pgxPool.Close() + + _, err = pgxPool.Exec(l.Harness().Ctx, "DELETE FROM new_channel_params") + if err != nil { + return fmt.Errorf("failed to delete new_channel_params: %w", err) + } + + if len(settings) == 0 { + return nil + } + + query := `INSERT INTO new_channel_params (validity, params, token) VALUES ` + first := true + for _, setting := range settings { + if !first { + query += `,` + } + + query += fmt.Sprintf( + `(%d, '{"min_msat": "%d", "proportional": %d, "max_idle_time": 4320, "max_client_to_self_delay": 432}', 'hello')`, + int64(setting.Validity.Seconds()), + setting.MinMsat, + setting.Proportional, + ) + + first = false + } + query += `;` + _, err = pgxPool.Exec(l.Harness().Ctx, query) + if err != nil { + return fmt.Errorf("failed to insert new_channel_params: %w", err) + } + + return nil +} + +func getLspdBinary() (string, error) { + if lspdExecutable != nil { + return *lspdExecutable, nil + } + + return exec.LookPath("lspd") +} + +func getMigrationsDir() (string, error) { + if lspdMigrationsDir != nil { + return *lspdMigrationsDir, nil + } + + return exec.LookPath("lspdmigrationsdir") +} + +type token struct { + token string +} + +func (t *token) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) { + m := make(map[string]string) + m["authorization"] = "Bearer " + t.token + return m, nil +} + +// RequireTransportSecurity indicates whether the credentials requires +// transport security. +func (t *token) RequireTransportSecurity() bool { + return false +} diff --git a/itest/lspd_test.go b/itest/lspd_test.go new file mode 100644 index 0000000..098d68a --- /dev/null +++ b/itest/lspd_test.go @@ -0,0 +1,166 @@ +package itest + +import ( + "fmt" + "log" + "testing" + "time" + + "github.com/breez/lntest" + "github.com/breez/lspd/config" +) + +var defaultTimeout time.Duration = time.Second * 120 + +func TestLspd(t *testing.T) { + testCases := allTestCases + runTests(t, testCases, "LND-lspd", lndLspFunc, lndClientFunc) + runTests(t, testCases, "CLN-lspd", clnLspFunc, clnClientFunc) +} + +func lndLspFunc(h *lntest.TestHarness, m *lntest.Miner, mem *mempoolApi, c *config.NodeConfig) LspNode { + return NewLndLspdNode(h, m, mem, "lsp", c) +} + +func clnLspFunc(h *lntest.TestHarness, m *lntest.Miner, mem *mempoolApi, c *config.NodeConfig) LspNode { + return NewClnLspdNode(h, m, mem, "lsp", c) +} + +func lndClientFunc(h *lntest.TestHarness, m *lntest.Miner) BreezClient { + return newLndBreezClient(h, m, "breez-client") +} + +func clnClientFunc(h *lntest.TestHarness, m *lntest.Miner) BreezClient { + return newClnBreezClient(h, m, "breez-client") +} + +func runTests( + t *testing.T, + testCases []*testCase, + prefix string, + lspFunc LspFunc, + clientFunc ClientFunc, +) { + for _, testCase := range testCases { + testCase := testCase + t.Run(fmt.Sprintf("%s: %s", prefix, testCase.name), func(t *testing.T) { + runTest(t, testCase, prefix, lspFunc, clientFunc) + }) + } +} + +func runTest( + t *testing.T, + testCase *testCase, + prefix string, + lspFunc LspFunc, + clientFunc ClientFunc, +) { + log.Printf("%s: Running test case '%s'", prefix, testCase.name) + var dd time.Duration + to := testCase.timeout + if to == dd { + to = defaultTimeout + } + + deadline := time.Now().Add(to) + log.Printf("Using deadline %v", deadline.String()) + + h := lntest.NewTestHarness(t, deadline) + defer h.TearDown() + + log.Printf("Creating miner") + miner := lntest.NewMiner(h) + miner.Start() + + log.Printf("Creating mempool api") + mem := NewMempoolApi(h) + mem.Start() + + log.Printf("Creating lsp") + var lsp LspNode + if !testCase.skipCreateLsp { + lsp = lspFunc(h, miner, mem, nil) + lsp.Start() + } + c := clientFunc(h, miner) + c.Start() + log.Printf("Run testcase") + testCase.test(&testParams{ + t: t, + h: h, + m: miner, + mem: mem, + c: c, + lsp: lsp, + lspFunc: lspFunc, + clientFunc: clientFunc, + }) +} + +type testCase struct { + name string + test func(t *testParams) + skipCreateLsp bool + timeout time.Duration +} + +var allTestCases = []*testCase{ + { + name: "testOpenZeroConfChannelOnReceive", + test: testOpenZeroConfChannelOnReceive, + }, + { + name: "testOpenZeroConfSingleHtlc", + test: testOpenZeroConfSingleHtlc, + }, + { + name: "testZeroReserve", + test: testZeroReserve, + }, + { + name: "testFailureBobOffline", + test: testFailureBobOffline, + }, + { + name: "testNoBalance", + test: testNoBalance, + }, + { + name: "testRegularForward", + test: testRegularForward, + }, + { + name: "testProbing", + test: testProbing, + }, + { + name: "testInvalidCltv", + test: testInvalidCltv, + }, + { + name: "registerPaymentWithTag", + test: registerPaymentWithTag, + }, + { + name: "testOpenZeroConfUtxo", + test: testOpenZeroConfUtxo, + skipCreateLsp: true, + }, + { + name: "testDynamicFeeFlow", + test: testDynamicFeeFlow, + }, + { + name: "testOfflineNotificationPaymentRegistered", + test: testOfflineNotificationPaymentRegistered, + }, + { + name: "testOfflineNotificationRegularForward", + test: testOfflineNotificationRegularForward, + }, + { + name: "testOfflineNotificationZeroConfChannel", + test: testOfflineNotificationZeroConfChannel, + }, +} diff --git a/itest/mempool_api.go b/itest/mempool_api.go new file mode 100644 index 0000000..44a5676 --- /dev/null +++ b/itest/mempool_api.go @@ -0,0 +1,85 @@ +package itest + +import ( + "encoding/json" + "fmt" + "net" + "net/http" + + "github.com/breez/lntest" +) + +type RecommendedFeesResponse struct { + FastestFee float64 `json:"fastestFee"` + HalfHourFee float64 `json:"halfHourFee"` + HourFee float64 `json:"hourFee"` + EconomyFee float64 `json:"economyFee"` + MinimumFee float64 `json:"minimumFee"` +} + +type mempoolApi struct { + addr string + h *lntest.TestHarness + fees *RecommendedFeesResponse + lis net.Listener +} + +func NewMempoolApi(h *lntest.TestHarness) *mempoolApi { + port, err := lntest.GetPort() + if err != nil { + h.T.Fatalf("Failed to get port for mempool api: %v", err) + } + + return &mempoolApi{ + addr: fmt.Sprintf("127.0.0.1:%d", port), + h: h, + fees: &RecommendedFeesResponse{ + MinimumFee: 1, + EconomyFee: 1, + HourFee: 1, + HalfHourFee: 1, + FastestFee: 1, + }, + } +} + +func (m *mempoolApi) Address() string { + return fmt.Sprintf("http://%s/api/v1/", m.addr) +} + +func (m *mempoolApi) SetFees(fees *RecommendedFeesResponse) { + m.fees = fees +} + +func (m *mempoolApi) Start() { + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/fees/recommended", func(w http.ResponseWriter, r *http.Request) { + j, err := json.Marshal(m.fees) + if err != nil { + m.h.T.Fatalf("Failed to marshal mempool fees: %v", err) + } + _, err = w.Write(j) + if err != nil { + m.h.T.Fatalf("Failed to write mempool response: %v", err) + } + }) + lis, err := net.Listen("tcp", m.addr) + if err != nil { + m.h.T.Fatalf("failed to start mempool api: %v", err) + } + + m.lis = lis + m.h.AddStoppable(m) + + go http.Serve(lis, mux) +} + +func (m *mempoolApi) Stop() error { + lis := m.lis + if lis == nil { + return nil + } + + m.lis = nil + return lis.Close() +} diff --git a/itest/no_balance_test.go b/itest/no_balance_test.go new file mode 100644 index 0000000..498edd3 --- /dev/null +++ b/itest/no_balance_test.go @@ -0,0 +1,56 @@ +package itest + +import ( + "log" + "time" + + "github.com/breez/lntest" + lspd "github.com/breez/lspd/rpc" + "github.com/stretchr/testify/assert" +) + +func testNoBalance(p *testParams) { + alice := lntest.NewClnNode(p.h, p.m, "Alice") + alice.Start() + alice.Fund(10000000) + + log.Print("Opening channel between Alice and the lsp") + channel := alice.OpenChannel(p.lsp.LightningNode(), &lntest.OpenChannelOptions{ + AmountSat: publicChanAmount, + }) + channelId := alice.WaitForChannelReady(channel) + + log.Printf("Adding bob's invoices") + outerAmountMsat := uint64(2100000) + innerAmountMsat := calculateInnerAmountMsat(p.lsp, outerAmountMsat, nil) + description := "Please pay me" + innerInvoice, outerInvoice := GenerateInvoices(p.BreezClient(), + generateInvoicesRequest{ + innerAmountMsat: innerAmountMsat, + outerAmountMsat: outerAmountMsat, + description: description, + lsp: p.lsp, + }) + p.BreezClient().SetHtlcAcceptor(innerAmountMsat) + + log.Print("Connecting bob to lspd") + p.BreezClient().Node().ConnectPeer(p.lsp.LightningNode()) + + log.Printf("Registering payment with lsp") + RegisterPayment(p.lsp, &lspd.PaymentInformation{ + PaymentHash: innerInvoice.paymentHash, + PaymentSecret: innerInvoice.paymentSecret, + Destination: p.BreezClient().Node().NodeId(), + IncomingAmountMsat: int64(outerAmountMsat), + OutgoingAmountMsat: int64(innerAmountMsat), + }, false) + + // TODO: Fix race waiting for htlc interceptor. + log.Printf("Waiting %v to allow htlc interceptor to activate.", htlcInterceptorDelay) + <-time.After(htlcInterceptorDelay) + + log.Printf("Alice paying") + route := constructRoute(p.lsp.LightningNode(), p.BreezClient().Node(), channelId, lntest.NewShortChanIDFromString("1x0x0"), outerAmountMsat) + _, err := alice.PayViaRoute(outerAmountMsat, outerInvoice.paymentHash, outerInvoice.paymentSecret, route) + assert.Contains(p.t, err.Error(), "WIRE_TEMPORARY_CHANNEL_FAILURE") +} diff --git a/itest/notification_service.go b/itest/notification_service.go new file mode 100644 index 0000000..85c856a --- /dev/null +++ b/itest/notification_service.go @@ -0,0 +1,59 @@ +package itest + +import ( + "context" + "net" + "net/http" +) + +type PaymentReceivedPayload struct { + Template string `json:"template" binding:"required,eq=payment_received"` + Data struct { + PaymentHash string `json:"payment_hash" binding:"required"` + } `json:"data"` +} + +type TxConfirmedPayload struct { + Template string `json:"template" binding:"required,eq=tx_confirmed"` + Data struct { + TxID string `json:"tx_id" binding:"required"` + } `json:"data"` +} + +type AddressTxsChangedPayload struct { + Template string `json:"template" binding:"required,eq=address_txs_changed"` + Data struct { + Address string `json:"address" binding:"required"` + } `json:"data"` +} + +type notificationDeliveryService struct { + addr string + handleFunc func(resp http.ResponseWriter, req *http.Request) +} + +func newNotificationDeliveryService( + addr string, + handleFunc func(resp http.ResponseWriter, req *http.Request), +) *notificationDeliveryService { + return ¬ificationDeliveryService{ + addr: addr, + handleFunc: handleFunc, + } +} + +func (s *notificationDeliveryService) Start(ctx context.Context) error { + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/notify", s.handleFunc) + lis, err := net.Listen("tcp", s.addr) + if err != nil { + return err + } + + go func() { + <-ctx.Done() + lis.Close() + }() + + return http.Serve(lis, mux) +} diff --git a/itest/notification_test.go b/itest/notification_test.go new file mode 100644 index 0000000..d4c0daf --- /dev/null +++ b/itest/notification_test.go @@ -0,0 +1,309 @@ +package itest + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "log" + "net/http" + "time" + + "github.com/breez/lntest" + "github.com/breez/lspd/notifications" + lspd "github.com/breez/lspd/rpc" + "github.com/btcsuite/btcd/btcec/v2/ecdsa" + "github.com/stretchr/testify/assert" +) + +func testOfflineNotificationPaymentRegistered(p *testParams) { + alice := lntest.NewClnNode(p.h, p.m, "Alice") + alice.Start() + alice.Fund(10000000) + p.lsp.LightningNode().Fund(10000000) + + log.Print("Opening channel between Alice and the lsp") + channel := alice.OpenChannel(p.lsp.LightningNode(), &lntest.OpenChannelOptions{ + AmountSat: publicChanAmount, + }) + channelId := alice.WaitForChannelReady(channel) + + log.Printf("Adding bob's invoices") + outerAmountMsat := uint64(2100000) + innerAmountMsat := calculateInnerAmountMsat(p.lsp, outerAmountMsat, nil) + description := "Please pay me" + innerInvoice, outerInvoice := GenerateInvoices(p.BreezClient(), + generateInvoicesRequest{ + innerAmountMsat: innerAmountMsat, + outerAmountMsat: outerAmountMsat, + description: description, + lsp: p.lsp, + }) + + log.Print("Connecting bob to lspd") + p.BreezClient().Node().ConnectPeer(p.lsp.LightningNode()) + + log.Printf("Registering payment with lsp") + RegisterPayment(p.lsp, &lspd.PaymentInformation{ + PaymentHash: innerInvoice.paymentHash, + PaymentSecret: innerInvoice.paymentSecret, + Destination: p.BreezClient().Node().NodeId(), + IncomingAmountMsat: int64(outerAmountMsat), + OutgoingAmountMsat: int64(innerAmountMsat), + }, false) + + // Kill the mobile client + log.Printf("Stopping breez client") + p.BreezClient().Stop() + + port, err := lntest.GetPort() + if err != nil { + assert.FailNow(p.t, "failed to get port for deliveeery service") + } + addr := fmt.Sprintf("127.0.0.1:%d", port) + delivered := make(chan struct{}) + + notify := newNotificationDeliveryService(addr, func(resp http.ResponseWriter, req *http.Request) { + var body PaymentReceivedPayload + err = json.NewDecoder(req.Body).Decode(&body) + assert.NoError(p.t, err) + + ph := hex.EncodeToString(innerInvoice.paymentHash) + assert.Equal(p.t, ph, body.Data.PaymentHash) + close(delivered) + }) + go notify.Start(p.h.Ctx) + go func() { + <-delivered + log.Printf("Starting breez client again") + p.BreezClient().SetHtlcAcceptor(innerAmountMsat) + p.BreezClient().Start() + p.BreezClient().Node().ConnectPeer(p.lsp.LightningNode()) + }() + + // TODO: Fix race waiting for htlc interceptor. + log.Printf("Waiting %v to allow htlc interceptor to activate.", htlcInterceptorDelay) + <-time.After(htlcInterceptorDelay) + + url := "http://" + addr + "/api/v1/notify" + first := sha256.Sum256([]byte(url)) + second := sha256.Sum256(first[:]) + sig, err := ecdsa.SignCompact(p.BreezClient().Node().PrivateKey(), second[:], true) + assert.NoError(p.t, err) + + p.lsp.NotificationsRpc().SubscribeNotifications(p.h.Ctx, ¬ifications.SubscribeNotificationsRequest{ + Url: url, + Signature: sig, + }) + log.Printf("Alice paying") + route := constructRoute(p.lsp.LightningNode(), p.BreezClient().Node(), channelId, lntest.NewShortChanIDFromString("1x0x0"), outerAmountMsat) + _, err = alice.PayViaRoute(outerAmountMsat, outerInvoice.paymentHash, outerInvoice.paymentSecret, route) + assert.Nil(p.t, err) +} + +func testOfflineNotificationRegularForward(p *testParams) { + alice := lntest.NewClnNode(p.h, p.m, "Alice") + alice.Start() + alice.Fund(10000000) + p.lsp.LightningNode().Fund(10000000) + p.BreezClient().Node().Fund(100000) + + log.Print("Opening channel between Alice and the lsp") + channelAL := alice.OpenChannel(p.lsp.LightningNode(), &lntest.OpenChannelOptions{ + AmountSat: publicChanAmount, + IsPublic: true, + }) + + log.Print("Opening channel between lsp and Breez client") + channelLB := p.lsp.LightningNode().OpenChannel(p.BreezClient().Node(), &lntest.OpenChannelOptions{ + AmountSat: 200000, + IsPublic: false, + }) + + log.Print("Waiting for channel between Alice and the lsp to be ready.") + alice.WaitForChannelReady(channelAL) + log.Print("Waiting for channel between LSP and Bob to be ready.") + p.lsp.LightningNode().WaitForChannelReady(channelLB) + p.BreezClient().Node().WaitForChannelReady(channelLB) + + port, err := lntest.GetPort() + if err != nil { + assert.FailNow(p.t, "failed to get port for deliveeery service") + } + addr := fmt.Sprintf("127.0.0.1:%d", port) + delivered := make(chan struct{}) + + notify := newNotificationDeliveryService(addr, func(resp http.ResponseWriter, req *http.Request) { + var body PaymentReceivedPayload + err = json.NewDecoder(req.Body).Decode(&body) + assert.NoError(p.t, err) + + close(delivered) + }) + go notify.Start(p.h.Ctx) + go func() { + <-delivered + log.Printf("Notification was delivered. Starting breez client again") + p.BreezClient().Start() + p.BreezClient().Node().ConnectPeer(p.lsp.LightningNode()) + }() + + url := "http://" + addr + "/api/v1/notify" + first := sha256.Sum256([]byte(url)) + second := sha256.Sum256(first[:]) + sig, err := ecdsa.SignCompact(p.BreezClient().Node().PrivateKey(), second[:], true) + assert.NoError(p.t, err) + + p.lsp.NotificationsRpc().SubscribeNotifications(p.h.Ctx, ¬ifications.SubscribeNotificationsRequest{ + Url: url, + Signature: sig, + }) + + <-time.After(time.Second * 2) + log.Printf("Adding bob's invoice") + amountMsat := uint64(2100000) + bobInvoice := p.BreezClient().Node().CreateBolt11Invoice(&lntest.CreateInvoiceOptions{ + AmountMsat: amountMsat, + IncludeHopHints: true, + }) + log.Printf(bobInvoice.Bolt11) + + log.Printf("Bob going offline") + p.BreezClient().Stop() + + // TODO: Fix race waiting for htlc interceptor. + log.Printf("Waiting %v to allow htlc interceptor to activate.", htlcInterceptorDelay) + <-time.After(htlcInterceptorDelay) + + log.Printf("Alice paying") + payResp := alice.Pay(bobInvoice.Bolt11) + invoiceResult := p.BreezClient().Node().GetInvoice(bobInvoice.PaymentHash) + + assert.Equal(p.t, payResp.PaymentPreimage, invoiceResult.PaymentPreimage) + assert.Equal(p.t, amountMsat, invoiceResult.AmountReceivedMsat) +} + +func testOfflineNotificationZeroConfChannel(p *testParams) { + alice := lntest.NewClnNode(p.h, p.m, "Alice") + alice.Start() + alice.Fund(10000000) + p.lsp.LightningNode().Fund(10000000) + + log.Print("Opening channel between Alice and the lsp") + channel := alice.OpenChannel(p.lsp.LightningNode(), &lntest.OpenChannelOptions{ + AmountSat: publicChanAmount, + IsPublic: true, + }) + channelId := alice.WaitForChannelReady(channel) + + log.Printf("Adding bob's invoices") + outerAmountMsat := uint64(2100000) + innerAmountMsat := calculateInnerAmountMsat(p.lsp, outerAmountMsat, nil) + description := "Please pay me" + innerInvoice, outerInvoice := GenerateInvoices(p.BreezClient(), + generateInvoicesRequest{ + innerAmountMsat: innerAmountMsat, + outerAmountMsat: outerAmountMsat, + description: description, + lsp: p.lsp, + }) + + log.Print("Connecting bob to lspd") + p.BreezClient().Node().ConnectPeer(p.lsp.LightningNode()) + p.BreezClient().SetHtlcAcceptor(innerAmountMsat) + + // TODO: Fix race waiting for htlc interceptor. + log.Printf("Waiting %v to allow htlc interceptor to activate.", htlcInterceptorDelay) + <-time.After(htlcInterceptorDelay) + + log.Printf("Registering payment with lsp") + RegisterPayment(p.lsp, &lspd.PaymentInformation{ + PaymentHash: innerInvoice.paymentHash, + PaymentSecret: innerInvoice.paymentSecret, + Destination: p.BreezClient().Node().NodeId(), + IncomingAmountMsat: int64(outerAmountMsat), + OutgoingAmountMsat: int64(innerAmountMsat), + }, false) + + expectedheight := p.Miner().GetBlockHeight() + log.Printf("Alice paying") + route := constructRoute(p.lsp.LightningNode(), p.BreezClient().Node(), channelId, lntest.NewShortChanIDFromString("1x0x0"), outerAmountMsat) + _, err := alice.PayViaRoute(outerAmountMsat, outerInvoice.paymentHash, outerInvoice.paymentSecret, route) + assert.Nil(p.t, err) + + <-time.After(time.Second * 2) + log.Printf("Adding bob's invoice for zero conf payment") + amountMsat := uint64(2100000) + + bobInvoice := p.BreezClient().Node().CreateBolt11Invoice(&lntest.CreateInvoiceOptions{ + AmountMsat: amountMsat, + IncludeHopHints: true, + }) + + invoiceWithHint := bobInvoice.Bolt11 + if !ContainsHopHint(p.t, bobInvoice.Bolt11) { + chans := p.BreezClient().Node().GetChannels() + assert.Len(p.t, chans, 1) + + var id lntest.ShortChannelID + if chans[0].RemoteAlias != nil { + id = *chans[0].RemoteAlias + } else if chans[0].LocalAlias != nil { + id = *chans[0].LocalAlias + } else { + id = chans[0].ShortChannelID + } + invoiceWithHint = AddHopHint(p.BreezClient(), bobInvoice.Bolt11, p.Lsp(), id, nil) + } + + log.Printf("Invoice with hint: %s", invoiceWithHint) + + // Kill the mobile client + log.Printf("Stopping breez client") + p.BreezClient().Stop() + p.BreezClient().ResetHtlcAcceptor() + + port, err := lntest.GetPort() + if err != nil { + assert.FailNow(p.t, "failed to get port for delivery service") + } + addr := fmt.Sprintf("127.0.0.1:%d", port) + delivered := make(chan struct{}) + + notify := newNotificationDeliveryService(addr, func(resp http.ResponseWriter, req *http.Request) { + var body PaymentReceivedPayload + err = json.NewDecoder(req.Body).Decode(&body) + assert.NoError(p.t, err) + + close(delivered) + }) + go notify.Start(p.h.Ctx) + go func() { + <-delivered + log.Printf("Starting breez client again") + p.BreezClient().Start() + p.BreezClient().Node().ConnectPeer(p.lsp.LightningNode()) + }() + + url := "http://" + addr + "/api/v1/notify" + first := sha256.Sum256([]byte(url)) + second := sha256.Sum256(first[:]) + sig, err := ecdsa.SignCompact(p.BreezClient().Node().PrivateKey(), second[:], true) + assert.NoError(p.t, err) + + p.lsp.NotificationsRpc().SubscribeNotifications(p.h.Ctx, ¬ifications.SubscribeNotificationsRequest{ + Url: url, + Signature: sig, + }) + + log.Printf("Alice paying zero conf invoice") + payResp := alice.Pay(invoiceWithHint) + invoiceResult := p.BreezClient().Node().GetInvoice(bobInvoice.PaymentHash) + + assert.Equal(p.t, payResp.PaymentPreimage, invoiceResult.PaymentPreimage) + assert.Equal(p.t, amountMsat, invoiceResult.AmountReceivedMsat) + + // Make sure we haven't accidentally mined blocks in between. + actualheight := p.Miner().GetBlockHeight() + assert.Equal(p.t, expectedheight, actualheight) +} diff --git a/itest/postgres.go b/itest/postgres.go new file mode 100644 index 0000000..5cb5047 --- /dev/null +++ b/itest/postgres.go @@ -0,0 +1,268 @@ +package itest + +import ( + "context" + "encoding/binary" + "fmt" + "io" + "log" + "os" + "path/filepath" + "sort" + "strconv" + "sync" + "time" + + "github.com/breez/lntest" + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" + "github.com/docker/go-connections/nat" + "github.com/jackc/pgx/v4/pgxpool" +) + +type PostgresContainer struct { + id string + password string + port uint32 + cli *client.Client + logfile string + isInitialized bool + isStarted bool + mtx sync.Mutex +} + +func NewPostgresContainer(logfile string) (*PostgresContainer, error) { + port, err := lntest.GetPort() + if err != nil { + return nil, fmt.Errorf("could not get port: %w", err) + } + + return &PostgresContainer{ + password: "pgpassword", + port: port, + logfile: logfile, + }, nil +} + +func (c *PostgresContainer) Start(ctx context.Context) error { + c.mtx.Lock() + defer c.mtx.Unlock() + + var err error + if c.isStarted { + return nil + } + + c.cli, err = client.NewClientWithOpts(client.FromEnv) + if err != nil { + return fmt.Errorf("could not create docker client: %w", err) + } + + if !c.isInitialized { + err := c.initialize(ctx) + if err != nil { + c.cli.Close() + return err + } + } + + err = c.cli.ContainerStart(ctx, c.id, types.ContainerStartOptions{}) + if err != nil { + c.cli.Close() + return fmt.Errorf("failed to start docker container '%s': %w", c.id, err) + } + c.isStarted = true + +HealthCheck: + for { + inspect, err := c.cli.ContainerInspect(ctx, c.id) + if err != nil { + c.cli.ContainerStop(ctx, c.id, nil) + c.cli.Close() + return fmt.Errorf("failed to inspect container '%s' during healthcheck: %w", c.id, err) + } + + status := inspect.State.Health.Status + switch status { + case "unhealthy": + c.cli.ContainerStop(ctx, c.id, nil) + c.cli.Close() + return fmt.Errorf("container '%s' unhealthy", c.id) + case "healthy": + for { + pgxPool, err := pgxpool.Connect(ctx, c.ConnectionString()) + if err == nil { + pgxPool.Close() + break HealthCheck + } + + <-time.After(50 * time.Millisecond) + } + default: + <-time.After(200 * time.Millisecond) + } + } + + go c.monitorLogs(ctx) + return nil +} + +func (c *PostgresContainer) initialize(ctx context.Context) error { + image := "postgres:15" + _, _, err := c.cli.ImageInspectWithRaw(ctx, image) + if err != nil { + if !client.IsErrNotFound(err) { + return fmt.Errorf("could not find docker image '%s': %w", image, err) + } + + pullReader, err := c.cli.ImagePull(ctx, image, types.ImagePullOptions{}) + if err != nil { + return fmt.Errorf("failed to pull docker image '%s': %w", image, err) + } + defer pullReader.Close() + + _, err = io.Copy(io.Discard, pullReader) + if err != nil { + return fmt.Errorf("failed to download docker image '%s': %w", image, err) + } + } + + createResp, err := c.cli.ContainerCreate(ctx, &container.Config{ + Image: image, + Cmd: []string{ + "postgres", + "-c", + "log_statement=all", + }, + Env: []string{ + "POSTGRES_DB=postgres", + "POSTGRES_PASSWORD=pgpassword", + "POSTGRES_USER=postgres", + }, + Healthcheck: &container.HealthConfig{ + Test: []string{"CMD-SHELL", "pg_isready -U postgres"}, + Interval: time.Second, + Timeout: time.Second, + Retries: 10, + }, + }, &container.HostConfig{ + PortBindings: nat.PortMap{ + "5432/tcp": []nat.PortBinding{ + {HostPort: strconv.FormatUint(uint64(c.port), 10)}, + }, + }, + }, + nil, + nil, + "", + ) + + if err != nil { + return fmt.Errorf("failed to create docker container: %w", err) + } + + c.id = createResp.ID + c.isInitialized = true + return nil +} + +func (c *PostgresContainer) Stop(ctx context.Context) error { + c.mtx.Lock() + defer c.mtx.Unlock() + + if !c.isStarted { + return nil + } + + defer c.cli.Close() + err := c.cli.ContainerStop(ctx, c.id, nil) + c.isStarted = false + return err +} + +func (c *PostgresContainer) Cleanup(ctx context.Context) error { + c.mtx.Lock() + defer c.mtx.Unlock() + cli, err := client.NewClientWithOpts(client.FromEnv) + if err != nil { + return err + } + defer cli.Close() + return cli.ContainerRemove(ctx, c.id, types.ContainerRemoveOptions{ + Force: true, + }) +} + +func (c *PostgresContainer) monitorLogs(ctx context.Context) { + i, err := c.cli.ContainerLogs(ctx, c.id, types.ContainerLogsOptions{ + ShowStderr: true, + ShowStdout: true, + Timestamps: false, + Follow: true, + Tail: "40", + }) + if err != nil { + log.Printf("Could not get container logs: %v", err) + return + } + defer i.Close() + + file, err := os.Create(c.logfile) + if err != nil { + log.Printf("Could not create container log file %v: %v", c.logfile, err) + return + } + defer file.Close() + + hdr := make([]byte, 8) + for { + _, err := i.Read(hdr) + if err != nil { + return + } + count := binary.BigEndian.Uint32(hdr[4:]) + dat := make([]byte, count) + _, err = i.Read(dat) + if err != nil { + return + } + _, err = file.Write(dat) + if err != nil { + return + } + } +} + +func (c *PostgresContainer) ConnectionString() string { + return fmt.Sprintf("postgres://postgres:%s@127.0.0.1:%d/postgres", c.password, c.port) +} + +func (c *PostgresContainer) RunMigrations(ctx context.Context, migrationDir string) error { + filenames, err := filepath.Glob(filepath.Join(migrationDir, "*.up.sql")) + if err != nil { + return fmt.Errorf("failed to glob migration files: %w", err) + } + + sort.Strings(filenames) + + pgxPool, err := pgxpool.Connect(ctx, c.ConnectionString()) + if err != nil { + return fmt.Errorf("failed to connect to postgres: %w", err) + } + defer pgxPool.Close() + + for _, filename := range filenames { + data, err := os.ReadFile(filename) + if err != nil { + return fmt.Errorf("failed to read migration file '%s': %w", filename, err) + } + + _, err = pgxPool.Exec(ctx, string(data)) + if err != nil { + return fmt.Errorf("failed to execute migration file '%s': %w", filename, err) + } + } + + return nil +} diff --git a/itest/probing_test.go b/itest/probing_test.go new file mode 100644 index 0000000..d961f6c --- /dev/null +++ b/itest/probing_test.go @@ -0,0 +1,75 @@ +package itest + +import ( + "crypto/sha256" + "log" + "time" + + "github.com/breez/lntest" + lspd "github.com/breez/lspd/rpc" + "github.com/stretchr/testify/assert" +) + +func testProbing(p *testParams) { + alice := lntest.NewClnNode(p.h, p.m, "Alice") + alice.Start() + alice.Fund(10000000) + p.lsp.LightningNode().Fund(10000000) + + log.Print("Opening channel between Alice and the lsp") + channel := alice.OpenChannel(p.lsp.LightningNode(), &lntest.OpenChannelOptions{ + AmountSat: publicChanAmount, + }) + channelId := alice.WaitForChannelReady(channel) + + log.Printf("Adding bob's invoices") + outerAmountMsat := uint64(2100000) + innerAmountMsat := calculateInnerAmountMsat(p.lsp, outerAmountMsat, nil) + description := "Please pay me" + innerInvoice, outerInvoice := GenerateInvoices(p.BreezClient(), + generateInvoicesRequest{ + innerAmountMsat: innerAmountMsat, + outerAmountMsat: outerAmountMsat, + description: description, + lsp: p.lsp, + }) + p.BreezClient().SetHtlcAcceptor(innerAmountMsat) + + log.Print("Connecting bob to lspd") + p.BreezClient().Node().ConnectPeer(p.lsp.LightningNode()) + + log.Printf("Registering payment with lsp") + RegisterPayment(p.lsp, &lspd.PaymentInformation{ + PaymentHash: innerInvoice.paymentHash, + PaymentSecret: innerInvoice.paymentSecret, + Destination: p.BreezClient().Node().NodeId(), + IncomingAmountMsat: int64(outerAmountMsat), + OutgoingAmountMsat: int64(innerAmountMsat), + }, false) + + // TODO: Fix race waiting for htlc interceptor. + log.Printf("Waiting %v to allow htlc interceptor to activate.", htlcInterceptorDelay) + <-time.After(htlcInterceptorDelay) + + h := sha256.New() + _, _ = h.Write([]byte("probing-01:")) + _, _ = h.Write(outerInvoice.paymentHash) + fakePaymentHash := h.Sum(nil) + + log.Printf("Alice paying with fake payment hash with Bob online %x", fakePaymentHash) + route := constructRoute(p.lsp.LightningNode(), p.BreezClient().Node(), channelId, lntest.NewShortChanIDFromString("1x0x0"), outerAmountMsat) + _, err := alice.PayViaRoute(outerAmountMsat, fakePaymentHash, outerInvoice.paymentSecret, route) + + // Expect incorrect or unknown payment details if the peer is online + assert.Contains(p.t, err.Error(), "WIRE_INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS") + + // Kill the mobile client + log.Printf("Stopping breez client") + p.BreezClient().Stop() + + log.Printf("Alice paying with fake payment hash with Bob offline %x", fakePaymentHash) + _, err = alice.PayViaRoute(outerAmountMsat, fakePaymentHash, outerInvoice.paymentSecret, route) + + // Expect unknown next peer if the peer is offline + assert.Contains(p.t, err.Error(), "WIRE_UNKNOWN_NEXT_PEER") +} diff --git a/itest/regular_forward_test.go b/itest/regular_forward_test.go new file mode 100644 index 0000000..cd788b6 --- /dev/null +++ b/itest/regular_forward_test.go @@ -0,0 +1,54 @@ +package itest + +import ( + "log" + "time" + + "github.com/breez/lntest" + "github.com/stretchr/testify/assert" +) + +func testRegularForward(p *testParams) { + alice := lntest.NewClnNode(p.h, p.m, "Alice") + alice.Start() + alice.Fund(10000000) + p.lsp.LightningNode().Fund(10000000) + p.BreezClient().Node().Fund(100000) + + log.Print("Opening channel between Alice and the lsp") + channelAL := alice.OpenChannel(p.lsp.LightningNode(), &lntest.OpenChannelOptions{ + AmountSat: publicChanAmount, + IsPublic: true, + }) + + log.Print("Opening channel between lsp and Breez client") + channelLB := p.lsp.LightningNode().OpenChannel(p.BreezClient().Node(), &lntest.OpenChannelOptions{ + AmountSat: 200000, + IsPublic: false, + }) + + log.Print("Waiting for channel between Alice and the lsp to be ready.") + alice.WaitForChannelReady(channelAL) + log.Print("Waiting for channel between LSP and Bob to be ready.") + p.lsp.LightningNode().WaitForChannelReady(channelLB) + p.BreezClient().Node().WaitForChannelReady(channelLB) + + // TODO: Fix race waiting for htlc interceptor. + log.Printf("Waiting %v to allow htlc interceptor to activate.", htlcInterceptorDelay) + <-time.After(htlcInterceptorDelay) + + log.Printf("Adding bob's invoice") + amountMsat := uint64(2100000) + bobInvoice := p.BreezClient().Node().CreateBolt11Invoice(&lntest.CreateInvoiceOptions{ + AmountMsat: amountMsat, + IncludeHopHints: true, + }) + log.Printf(bobInvoice.Bolt11) + + log.Printf("Alice paying") + payResp := alice.Pay(bobInvoice.Bolt11) + invoiceResult := p.BreezClient().Node().GetInvoice(bobInvoice.PaymentHash) + + assert.Equal(p.t, payResp.PaymentPreimage, invoiceResult.PaymentPreimage) + assert.Equal(p.t, amountMsat, invoiceResult.AmountReceivedMsat) +} diff --git a/itest/tag_test.go b/itest/tag_test.go new file mode 100644 index 0000000..65af104 --- /dev/null +++ b/itest/tag_test.go @@ -0,0 +1,55 @@ +package itest + +import ( + "log" + + "github.com/breez/lntest" + lspd "github.com/breez/lspd/rpc" + "github.com/jackc/pgx/v4/pgxpool" + "github.com/stretchr/testify/assert" +) + +func registerPaymentWithTag(p *testParams) { + expected := "{\"apiKey\": \"11111111111111\"}" + i := p.BreezClient().Node().CreateBolt11Invoice(&lntest.CreateInvoiceOptions{ + AmountMsat: 25000000, + }) + + log.Printf("Registering payment with lsp") + RegisterPayment(p.lsp, &lspd.PaymentInformation{ + PaymentHash: i.PaymentHash, + PaymentSecret: i.PaymentSecret, + Destination: p.BreezClient().Node().NodeId(), + IncomingAmountMsat: int64(25000000), + OutgoingAmountMsat: int64(21000000), + Tag: expected, + }, false) + + pgxPool, err := pgxpool.Connect(p.h.Ctx, p.lsp.PostgresBackend().ConnectionString()) + if err != nil { + p.h.T.Fatalf("Failed to connect to postgres backend: %v", err) + } + defer pgxPool.Close() + + rows, err := pgxPool.Query( + p.h.Ctx, + "SELECT tag FROM public.payments WHERE payment_hash=$1", + i.PaymentHash, + ) + if err != nil { + p.h.T.Fatalf("Failed to query tag: %v", err) + } + + defer rows.Close() + if !rows.Next() { + p.h.T.Fatal("No rows found") + } + + var actual string + err = rows.Scan(&actual) + if err != nil { + p.h.T.Fatalf("Failed to get tag from row: %v", err) + } + + assert.Equal(p.h.T, expected, actual) +} diff --git a/itest/test_common.go b/itest/test_common.go new file mode 100644 index 0000000..76fe7e0 --- /dev/null +++ b/itest/test_common.go @@ -0,0 +1,54 @@ +package itest + +import ( + "crypto/rand" + "log" + "testing" + + lspd "github.com/breez/lspd/rpc" + "github.com/stretchr/testify/assert" +) + +func GenerateRandomBytes(n int) ([]byte, error) { + b := make([]byte, n) + _, err := rand.Read(b) + // Note that err == nil only if we read len(b) bytes. + if err != nil { + return nil, err + } + + return b, nil +} + +func AssertChannelCapacity( + t *testing.T, + outerAmountMsat uint64, + capacityMsat uint64, +) { + assert.Equal(t, ((outerAmountMsat/1000)+100000)*1000, capacityMsat) +} + +func calculateInnerAmountMsat(lsp LspNode, outerAmountMsat uint64, params *lspd.OpeningFeeParams) uint64 { + var fee uint64 + log.Printf("%+v", params) + if params == nil { + fee = outerAmountMsat * 40 / 10_000 / 1_000 * 1_000 + if fee < 2000000 { + fee = 2000000 + } + } else { + fee = outerAmountMsat * uint64(params.Proportional) / 1_000_000 / 1_000 * 1_000 + if fee < params.MinMsat { + fee = params.MinMsat + } + } + + if fee > outerAmountMsat { + lsp.Harness().Fatalf("Fee is higher than amount") + } + + log.Printf("outer: %v, fee: %v", outerAmountMsat, fee) + return outerAmountMsat - fee +} + +var publicChanAmount uint64 = 1000183 diff --git a/itest/test_params.go b/itest/test_params.go new file mode 100644 index 0000000..ab9b416 --- /dev/null +++ b/itest/test_params.go @@ -0,0 +1,46 @@ +package itest + +import ( + "testing" + + "github.com/breez/lntest" + "github.com/breez/lspd/config" +) + +type LspFunc func(h *lntest.TestHarness, m *lntest.Miner, mem *mempoolApi, c *config.NodeConfig) LspNode +type ClientFunc func(h *lntest.TestHarness, m *lntest.Miner) BreezClient + +type testParams struct { + t *testing.T + h *lntest.TestHarness + m *lntest.Miner + mem *mempoolApi + c BreezClient + lsp LspNode + lspFunc LspFunc + clientFunc ClientFunc +} + +func (h *testParams) T() *testing.T { + return h.t +} + +func (h *testParams) Miner() *lntest.Miner { + return h.m +} + +func (h *testParams) Mempool() *mempoolApi { + return h.mem +} + +func (h *testParams) Lsp() LspNode { + return h.lsp +} + +func (h *testParams) Harness() *lntest.TestHarness { + return h.h +} + +func (h *testParams) BreezClient() BreezClient { + return h.c +} diff --git a/itest/zero_conf_utxo_test.go b/itest/zero_conf_utxo_test.go new file mode 100644 index 0000000..30ea394 --- /dev/null +++ b/itest/zero_conf_utxo_test.go @@ -0,0 +1,77 @@ +package itest + +import ( + "log" + "time" + + "github.com/breez/lntest" + "github.com/breez/lspd/config" + lspd "github.com/breez/lspd/rpc" + "github.com/stretchr/testify/assert" +) + +func testOpenZeroConfUtxo(p *testParams) { + alice := lntest.NewClnNode(p.h, p.m, "Alice") + alice.Start() + alice.Fund(10000000) + + minConfs := uint32(0) + lsp := p.lspFunc(p.h, p.m, p.mem, &config.NodeConfig{MinConfs: &minConfs}) + lsp.Start() + + log.Print("Opening channel between Alice and the lsp") + channel := alice.OpenChannel(lsp.LightningNode(), &lntest.OpenChannelOptions{ + AmountSat: publicChanAmount, + }) + channelId := alice.WaitForChannelReady(channel) + + tempaddr := lsp.LightningNode().GetNewAddress() + p.m.SendToAddress(tempaddr, 210000) + p.m.MineBlocks(6) + lsp.LightningNode().WaitForSync() + + initialHeight := p.m.GetBlockHeight() + addr := lsp.LightningNode().GetNewAddress() + lsp.LightningNode().SendToAddress(addr, 200000) + + log.Printf("Adding bob's invoices") + outerAmountMsat := uint64(2100000) + innerAmountMsat := calculateInnerAmountMsat(lsp, outerAmountMsat, nil) + description := "Please pay me" + innerInvoice, outerInvoice := GenerateInvoices(p.BreezClient(), + generateInvoicesRequest{ + innerAmountMsat: innerAmountMsat, + outerAmountMsat: outerAmountMsat, + description: description, + lsp: lsp, + }) + p.BreezClient().SetHtlcAcceptor(innerAmountMsat) + + log.Print("Connecting bob to lspd") + p.BreezClient().Node().ConnectPeer(lsp.LightningNode()) + + log.Printf("Registering payment with lsp") + RegisterPayment(lsp, &lspd.PaymentInformation{ + PaymentHash: innerInvoice.paymentHash, + PaymentSecret: innerInvoice.paymentSecret, + Destination: p.BreezClient().Node().NodeId(), + IncomingAmountMsat: int64(outerAmountMsat), + OutgoingAmountMsat: int64(innerAmountMsat), + }, false) + + // TODO: Fix race waiting for htlc interceptor. + log.Printf("Waiting %v to allow htlc interceptor to activate.", htlcInterceptorDelay) + <-time.After(htlcInterceptorDelay) + log.Printf("Alice paying") + route := constructRoute(lsp.LightningNode(), p.BreezClient().Node(), channelId, lntest.NewShortChanIDFromString("1x0x0"), outerAmountMsat) + payResp, err := alice.PayViaRoute(outerAmountMsat, outerInvoice.paymentHash, outerInvoice.paymentSecret, route) + lntest.CheckError(p.t, err) + bobInvoice := p.BreezClient().Node().GetInvoice(payResp.PaymentHash) + + assert.Equal(p.t, payResp.PaymentPreimage, bobInvoice.PaymentPreimage) + assert.Equal(p.t, innerAmountMsat, bobInvoice.AmountReceivedMsat) + + // Make sure there's not accidently a block mined in between + finalHeight := p.m.GetBlockHeight() + assert.Equal(p.t, initialHeight, finalHeight) +} diff --git a/itest/zero_reserve_test.go b/itest/zero_reserve_test.go new file mode 100644 index 0000000..fad91b3 --- /dev/null +++ b/itest/zero_reserve_test.go @@ -0,0 +1,64 @@ +package itest + +import ( + "log" + "time" + + "github.com/breez/lntest" + lspd "github.com/breez/lspd/rpc" + "github.com/stretchr/testify/assert" +) + +func testZeroReserve(p *testParams) { + alice := lntest.NewClnNode(p.h, p.m, "Alice") + alice.Start() + alice.Fund(10000000) + p.lsp.LightningNode().Fund(10000000) + + log.Print("Opening channel between Alice and the lsp") + channel := alice.OpenChannel(p.lsp.LightningNode(), &lntest.OpenChannelOptions{ + AmountSat: publicChanAmount, + }) + channelId := alice.WaitForChannelReady(channel) + + log.Printf("Adding bob's invoices") + outerAmountMsat := uint64(2100000) + innerAmountMsat := calculateInnerAmountMsat(p.lsp, outerAmountMsat, nil) + description := "Please pay me" + innerInvoice, outerInvoice := GenerateInvoices(p.BreezClient(), + generateInvoicesRequest{ + innerAmountMsat: innerAmountMsat, + outerAmountMsat: outerAmountMsat, + description: description, + lsp: p.lsp, + }) + p.BreezClient().SetHtlcAcceptor(innerAmountMsat) + + log.Print("Connecting bob to lspd") + p.BreezClient().Node().ConnectPeer(p.lsp.LightningNode()) + + log.Printf("Registering payment with lsp") + RegisterPayment(p.lsp, &lspd.PaymentInformation{ + PaymentHash: innerInvoice.paymentHash, + PaymentSecret: innerInvoice.paymentSecret, + Destination: p.BreezClient().Node().NodeId(), + IncomingAmountMsat: int64(outerAmountMsat), + OutgoingAmountMsat: int64(innerAmountMsat), + }, false) + + // TODO: Fix race waiting for htlc interceptor. + log.Printf("Waiting %v to allow htlc interceptor to activate.", htlcInterceptorDelay) + <-time.After(htlcInterceptorDelay) + log.Printf("Alice paying") + route := constructRoute(p.lsp.LightningNode(), p.BreezClient().Node(), channelId, lntest.NewShortChanIDFromString("1x0x0"), outerAmountMsat) + alice.PayViaRoute(outerAmountMsat, outerInvoice.paymentHash, outerInvoice.paymentSecret, route) + + // Make sure balance is correct + chans := p.BreezClient().Node().GetChannels() + assert.Equal(p.t, 1, len(chans)) + + c := chans[0] + assert.Equal(p.t, c.RemoteReserveMsat, c.CapacityMsat/100) + log.Printf("local reserve: %d, remote reserve: %d", c.LocalReserveMsat, c.RemoteReserveMsat) + assert.Zero(p.t, c.LocalReserveMsat) +} diff --git a/lightning/client.go b/lightning/client.go new file mode 100644 index 0000000..009e562 --- /dev/null +++ b/lightning/client.go @@ -0,0 +1,41 @@ +package lightning + +import ( + "time" + + "github.com/breez/lspd/basetypes" + "github.com/btcsuite/btcd/wire" +) + +type GetInfoResult struct { + Alias string + Pubkey string +} + +type GetChannelResult struct { + InitialChannelID basetypes.ShortChannelID + ConfirmedChannelID basetypes.ShortChannelID +} + +type OpenChannelRequest struct { + Destination []byte + CapacitySat uint64 + MinHtlcMsat uint64 + IsPrivate bool + IsZeroConf bool + MinConfs *uint32 + FeeSatPerVByte *float64 + TargetConf *uint32 +} + +type Client interface { + GetInfo() (*GetInfoResult, error) + IsConnected(destination []byte) (bool, error) + OpenChannel(req *OpenChannelRequest) (*wire.OutPoint, error) + GetChannel(peerID []byte, channelPoint wire.OutPoint) (*GetChannelResult, error) + GetPeerId(scid *basetypes.ShortChannelID) ([]byte, error) + GetNodeChannelCount(nodeID []byte) (int, error) + GetClosedChannels(nodeID string, channelPoints map[string]uint64) (map[string]uint64, error) + WaitOnline(peerID []byte, deadline time.Time) error + WaitChannelActive(peerID []byte, deadline time.Time) error +} diff --git a/lnd/client.go b/lnd/client.go new file mode 100644 index 0000000..90ae714 --- /dev/null +++ b/lnd/client.go @@ -0,0 +1,528 @@ +package lnd + +import ( + "context" + "crypto/x509" + "encoding/hex" + "fmt" + "log" + "sync" + "time" + + "github.com/breez/lspd/basetypes" + "github.com/breez/lspd/config" + "github.com/breez/lspd/lightning" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/htlcswitch/hop" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/chainrpc" + "github.com/lightningnetwork/lnd/lnrpc/routerrpc" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/status" +) + +type LndClient struct { + client lnrpc.LightningClient + routerClient routerrpc.RouterClient + chainNotifierClient chainrpc.ChainNotifierClient + conn *grpc.ClientConn + listenerCtx context.Context + listenerCancel context.CancelFunc + peersubs map[string]map[uint64]chan struct{} + chansubs map[string]map[uint64]chan struct{} + submtx sync.RWMutex + index uint64 +} + +func NewLndClient(conf *config.LndConfig) (*LndClient, error) { + _, err := hex.DecodeString(conf.Macaroon) + if err != nil { + return nil, fmt.Errorf("failed to decode macaroon: %w", err) + } + + // Creds file to connect to LND gRPC + cp := x509.NewCertPool() + if !cp.AppendCertsFromPEM([]byte(conf.Cert)) { + return nil, fmt.Errorf("credentials: failed to append certificates") + } + creds := credentials.NewClientTLSFromCert(cp, "") + macCred := NewMacaroonCredential(conf.Macaroon) + + // Address of an LND instance + conn, err := grpc.Dial( + conf.Address, + grpc.WithTransportCredentials(creds), + grpc.WithPerRPCCredentials(macCred), + ) + if err != nil { + log.Fatalf("Failed to connect to LND gRPC: %v", err) + } + + client := lnrpc.NewLightningClient(conn) + routerClient := routerrpc.NewRouterClient(conn) + chainNotifierClient := chainrpc.NewChainNotifierClient(conn) + return &LndClient{ + client: client, + routerClient: routerClient, + chainNotifierClient: chainNotifierClient, + conn: conn, + peersubs: make(map[string]map[uint64]chan struct{}), + chansubs: make(map[string]map[uint64]chan struct{}), + }, nil +} + +func (c *LndClient) Close() { + cancel := c.listenerCancel + if cancel != nil { + cancel() + } + c.conn.Close() +} + +func (c *LndClient) StartListeners() { + c.listenerCtx, c.listenerCancel = context.WithCancel(context.Background()) + go c.listenPeerEvents() + go c.listenChannelEvents() +} + +func (c *LndClient) listenPeerEvents() { + ctx := c.listenerCtx + for { + if ctx.Err() != nil { + return + } + + sub, err := c.client.SubscribePeerEvents( + ctx, + &lnrpc.PeerEventSubscription{}, + ) + if err != nil { + log.Printf("SubscribePeerEvents: %v", err) + <-time.After(time.Second) + continue + } + + for { + if ctx.Err() != nil { + return + } + + msg, err := sub.Recv() + if err != nil { + status, ok := status.FromError(err) + if ok && status.Code() == codes.Canceled { + log.Printf("listenPeerEvents: Got code canceled. Break.") + break + } + + log.Printf("unexpected error in listenPeerEvents: %v", err) + break + } + + if msg.Type != lnrpc.PeerEvent_PEER_ONLINE { + continue + } + + c.submtx.RLock() + subs, ok := c.peersubs[msg.PubKey] + if ok { + for _, sub := range subs { + sub <- struct{}{} + } + } + c.submtx.RUnlock() + } + + <-time.After(time.Second) + } +} + +func (c *LndClient) listenChannelEvents() { + ctx := c.listenerCtx + for { + if ctx.Err() != nil { + return + } + + sub, err := c.client.SubscribeChannelEvents( + ctx, + &lnrpc.ChannelEventSubscription{}, + ) + if err != nil { + log.Printf("listenChannelEvents: SubscribeChannelEvents: %v", err) + <-time.After(time.Second) + continue + } + + for { + if ctx.Err() != nil { + return + } + + msg, err := sub.Recv() + if err != nil { + status, ok := status.FromError(err) + if ok && status.Code() == codes.Canceled { + log.Printf("listenChannelEvents: Got code canceled. Break.") + break + } + + log.Printf("unexpected error in listenChannelEvents: %v", err) + break + } + + if msg.Type != lnrpc.ChannelEventUpdate_ACTIVE_CHANNEL { + continue + } + + ch := msg.GetActiveChannel() + point, err := extractChannelPoint(ch) + if err != nil { + log.Printf("listenChannelEvents: Failed to extract channel point %+v: %v", ch, err) + continue + } + + c.submtx.RLock() + subs, ok := c.chansubs[point] + if ok { + for _, sub := range subs { + sub <- struct{}{} + } + } + c.submtx.RUnlock() + } + + <-time.After(time.Second) + } +} + +func extractChannelPoint(cp *lnrpc.ChannelPoint) (string, error) { + str := cp.GetFundingTxidStr() + if str == "" { + b := cp.GetFundingTxidBytes() + h, err := chainhash.NewHash(b) + if err != nil { + return "", err + } + str = h.String() + } + + return fmt.Sprintf("%s:%d", str, cp.OutputIndex), nil +} + +func (c *LndClient) GetInfo() (*lightning.GetInfoResult, error) { + info, err := c.client.GetInfo(context.Background(), &lnrpc.GetInfoRequest{}) + if err != nil { + log.Printf("LND: client.GetInfo() error: %v", err) + return nil, err + } + + return &lightning.GetInfoResult{ + Alias: info.Alias, + Pubkey: info.IdentityPubkey, + }, nil +} + +func (c *LndClient) IsConnected(destination []byte) (bool, error) { + pubkey := hex.EncodeToString(destination) + + r, err := c.client.GetPeerConnected(context.Background(), &lnrpc.GetPeerConnectedRequest{ + Pubkey: pubkey, + }) + if err != nil { + log.Printf("LND: client.GetPeerConnected() error: %v", err) + return false, fmt.Errorf("LND: client.GetPeerConnected() error: %w", err) + } + if r.Connected { + log.Printf("LND: destination online: %x", destination) + return true, nil + } + + log.Printf("LND: destination offline: %x", destination) + return false, nil +} + +func (c *LndClient) OpenChannel(req *lightning.OpenChannelRequest) (*wire.OutPoint, error) { + lnReq := &lnrpc.OpenChannelRequest{ + NodePubkey: req.Destination, + LocalFundingAmount: int64(req.CapacitySat), + PushSat: 0, + Private: req.IsPrivate, + CommitmentType: lnrpc.CommitmentType_ANCHORS, + ZeroConf: req.IsZeroConf, + } + + if req.MinConfs != nil { + minConfs := *req.MinConfs + lnReq.MinConfs = int32(minConfs) + if minConfs == 0 { + lnReq.SpendUnconfirmed = true + } + } + + if req.FeeSatPerVByte != nil { + lnReq.SatPerVbyte = uint64(*req.FeeSatPerVByte) + } else if req.TargetConf != nil { + lnReq.TargetConf = int32(*req.TargetConf) + } + + channelPoint, err := c.client.OpenChannelSync(context.Background(), lnReq) + if err != nil { + log.Printf("LND: client.OpenChannelSync(%x, %v) error: %v", req.Destination, req.CapacitySat, err) + return nil, fmt.Errorf("LND: OpenChannel() error: %w", err) + } + + result, err := basetypes.NewOutPoint(channelPoint.GetFundingTxidBytes(), channelPoint.OutputIndex) + if err != nil { + log.Printf("LND: OpenChannel returned invalid outpoint. error: %v", err) + return nil, err + } + + return result, nil +} + +func (c *LndClient) GetChannel(peerID []byte, channelPoint wire.OutPoint) (*lightning.GetChannelResult, error) { + r, err := c.client.ListChannels(context.Background(), &lnrpc.ListChannelsRequest{Peer: peerID}) + if err != nil { + log.Printf("client.ListChannels(%x) error: %v", peerID, err) + return nil, err + } + + channelPointStr := channelPoint.String() + if err != nil { + return nil, err + } + + for _, c := range r.Channels { + log.Printf("getChannel(%x): %v", peerID, c.ChanId) + if c.ChannelPoint == channelPointStr && c.Active { + confirmedChanId := c.ChanId + if c.ZeroConf { + confirmedChanId = c.ZeroConfConfirmedScid + if confirmedChanId == hop.Source.ToUint64() { + confirmedChanId = 0 + } + } + return &lightning.GetChannelResult{ + InitialChannelID: basetypes.ShortChannelID(c.ChanId), + ConfirmedChannelID: basetypes.ShortChannelID(confirmedChanId), + }, nil + } + } + log.Printf("No channel found: getChannel(%x)", peerID) + return nil, fmt.Errorf("no channel found") +} + +func (c *LndClient) GetNodeChannelCount(nodeID []byte) (int, error) { + nodeIDStr := hex.EncodeToString(nodeID) + listResponse, err := c.client.ListChannels(context.Background(), &lnrpc.ListChannelsRequest{}) + if err != nil { + return 0, err + } + + pendingResponse, err := c.client.PendingChannels(context.Background(), &lnrpc.PendingChannelsRequest{}) + if err != nil { + return 0, err + } + + count := 0 + for _, channel := range listResponse.Channels { + if channel.RemotePubkey == nodeIDStr { + count++ + } + } + + for _, p := range pendingResponse.PendingOpenChannels { + if p.Channel.RemoteNodePub == nodeIDStr { + count++ + } + } + + return count, nil +} + +func (c *LndClient) GetClosedChannels(nodeID string, channelPoints map[string]uint64) (map[string]uint64, error) { + r := make(map[string]uint64) + if len(channelPoints) == 0 { + return r, nil + } + waitingCloseChannels, err := c.getWaitingCloseChannels(nodeID) + if err != nil { + return nil, err + } + wcc := make(map[string]struct{}) + for _, c := range waitingCloseChannels { + wcc[c.Channel.ChannelPoint] = struct{}{} + } + for c, h := range channelPoints { + if _, ok := wcc[c]; !ok { + r[c] = h + } + } + return r, nil +} + +func (c *LndClient) getWaitingCloseChannels(nodeID string) ([]*lnrpc.PendingChannelsResponse_WaitingCloseChannel, error) { + pendingResponse, err := c.client.PendingChannels(context.Background(), &lnrpc.PendingChannelsRequest{}) + if err != nil { + return nil, err + } + var waitingCloseChannels []*lnrpc.PendingChannelsResponse_WaitingCloseChannel + for _, p := range pendingResponse.WaitingCloseChannels { + if p.Channel.RemoteNodePub == nodeID { + waitingCloseChannels = append(waitingCloseChannels, p) + } + } + return waitingCloseChannels, nil +} + +func (c *LndClient) GetPeerId(scid *basetypes.ShortChannelID) ([]byte, error) { + scidu64 := uint64(*scid) + peer, err := c.client.GetPeerIdByScid(context.Background(), &lnrpc.GetPeerIdByScidRequest{ + Scid: scidu64, + }) + if err != nil { + return nil, err + } + + if peer.PeerId == "" { + return nil, nil + } + + peerid, _ := hex.DecodeString(peer.PeerId) + return peerid, nil +} + +func (c *LndClient) WaitOnline(peerID []byte, deadline time.Time) error { + pkStr := hex.EncodeToString(peerID) + signal := make(chan struct{}, 10) + defer close(signal) + + c.submtx.Lock() + subid := c.index + c.index++ + subs, ok := c.peersubs[pkStr] + if !ok { + subs = make(map[uint64]chan struct{}) + c.peersubs[pkStr] = subs + } + + subs[subid] = signal + c.submtx.Unlock() + + defer func() { + c.submtx.Lock() + subs, ok := c.peersubs[pkStr] + if ok { + delete(subs, subid) + if len(subs) == 0 { + delete(c.peersubs, pkStr) + } + } + c.submtx.Unlock() + }() + + connected, err := c.IsConnected(peerID) + if err != nil { + return err + } + if connected { + return nil + } + + select { + case <-signal: + return nil + case <-time.After(time.Until(deadline)): + return fmt.Errorf("deadline exceeded") + } +} + +func (c *LndClient) WaitChannelActive(peerID []byte, deadline time.Time) error { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Fetch the channels for this peer + chans, err := c.client.ListChannels(ctx, &lnrpc.ListChannelsRequest{ + Peer: peerID, + }) + if err != nil { + return err + } + if len(chans.Channels) == 0 { + return fmt.Errorf("no channels with peer") + } + + // Exit now if a channel is already active. + for _, ch := range chans.Channels { + if ch.Active { + return nil + } + } + + signal := make(chan struct{}, 10) + defer close(signal) + + // Subscribe to channel active events from this channel + c.submtx.Lock() + for _, ch := range chans.Channels { + chansignal := make(chan struct{}, 10) + defer close(chansignal) + + // forward signals from all channels to the signal aggregate + go func(c chan struct{}) { + for msg := range c { + signal <- msg + } + }(chansignal) + + outpoint := ch.ChannelPoint + subid := c.index + c.index++ + subs, ok := c.chansubs[outpoint] + if !ok { + subs = make(map[uint64]chan struct{}) + c.chansubs[outpoint] = subs + } + + subs[subid] = chansignal + defer func() { + c.submtx.Lock() + subs, ok := c.chansubs[outpoint] + if ok { + delete(subs, subid) + if len(subs) == 0 { + delete(c.chansubs, outpoint) + } + } + c.submtx.Unlock() + }() + } + c.submtx.Unlock() + + // Fetch the channels for this peer again, so there is no gap between the + // subscription and the call. + chans, err = c.client.ListChannels(ctx, &lnrpc.ListChannelsRequest{ + Peer: peerID, + }) + if err != nil { + return err + } + + // Exit now if a channel is already active. + for _, ch := range chans.Channels { + if ch.Active { + return nil + } + } + + select { + case <-signal: + return nil + case <-time.After(time.Until(deadline)): + return fmt.Errorf("deadline exceeded") + } +} diff --git a/lnd/forwarding_event_store.go b/lnd/forwarding_event_store.go new file mode 100644 index 0000000..22fc4a4 --- /dev/null +++ b/lnd/forwarding_event_store.go @@ -0,0 +1,12 @@ +package lnd + +type CopyFromSource interface { + Next() bool + Values() ([]interface{}, error) + Err() error +} + +type ForwardingEventStore interface { + LastForwardingEvent() (int64, error) + InsertForwardingEvents(rowSrc CopyFromSource) error +} diff --git a/lnd/forwarding_history.go b/lnd/forwarding_history.go new file mode 100644 index 0000000..debd93f --- /dev/null +++ b/lnd/forwarding_history.go @@ -0,0 +1,183 @@ +package lnd + +import ( + "context" + "encoding/hex" + "fmt" + "log" + "time" + + "github.com/breez/lspd/interceptor" + "github.com/lightningnetwork/lnd/htlcswitch/hop" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/chainrpc" +) + +type copyFromEvents struct { + events []*lnrpc.ForwardingEvent + idx int + err error +} + +func (cfe *copyFromEvents) Next() bool { + cfe.idx++ + return cfe.idx < len(cfe.events) +} + +func (cfe *copyFromEvents) Values() ([]interface{}, error) { + event := cfe.events[cfe.idx] + values := []interface{}{ + event.TimestampNs, + int64(event.ChanIdIn), int64(event.ChanIdOut), + event.AmtInMsat, event.AmtOutMsat} + return values, nil +} + +func (cfe *copyFromEvents) Err() error { + return cfe.err +} + +type ForwardingHistorySync struct { + client *LndClient + interceptStore interceptor.InterceptStore + forwardingStore ForwardingEventStore +} + +func NewForwardingHistorySync( + client *LndClient, + interceptStore interceptor.InterceptStore, + forwardingStore ForwardingEventStore, +) *ForwardingHistorySync { + return &ForwardingHistorySync{ + client: client, + interceptStore: interceptStore, + forwardingStore: forwardingStore, + } +} + +func (s *ForwardingHistorySync) ChannelsSynchronize(ctx context.Context) { + lastSync := time.Now().Add(-6 * time.Minute) + for { + if ctx.Err() != nil { + return + } + + stream, err := s.client.chainNotifierClient.RegisterBlockEpochNtfn(ctx, &chainrpc.BlockEpoch{}) + if err != nil { + log.Printf("chainNotifierClient.RegisterBlockEpochNtfn(): %v", err) + <-time.After(time.Second) + continue + } + + for { + if ctx.Err() != nil { + return + } + + _, err := stream.Recv() + if err != nil { + log.Printf("stream.Recv: %v", err) + <-time.After(time.Second) + break + } + if lastSync.Add(5 * time.Minute).Before(time.Now()) { + select { + case <-ctx.Done(): + return + case <-time.After(1 * time.Minute): + } + err = s.ChannelsSynchronizeOnce() + lastSync = time.Now() + log.Printf("channelsSynchronizeOnce() err: %v", err) + } + } + } +} + +func (s *ForwardingHistorySync) ChannelsSynchronizeOnce() error { + log.Printf("channelsSynchronizeOnce - begin") + channels, err := s.client.client.ListChannels(context.Background(), &lnrpc.ListChannelsRequest{PrivateOnly: true}) + if err != nil { + log.Printf("ListChannels error: %v", err) + return fmt.Errorf("client.ListChannels() error: %w", err) + } + log.Printf("channelsSynchronizeOnce - received channels") + lastUpdate := time.Now() + for _, c := range channels.Channels { + nodeID, err := hex.DecodeString(c.RemotePubkey) + if err != nil { + log.Printf("hex.DecodeString in channelsSynchronizeOnce error: %v", err) + continue + } + confirmedChanId := c.ChanId + if c.ZeroConf { + confirmedChanId = c.ZeroConfConfirmedScid + if confirmedChanId == hop.Source.ToUint64() { + confirmedChanId = 0 + } + } + err = s.interceptStore.InsertChannel(c.ChanId, confirmedChanId, c.ChannelPoint, nodeID, lastUpdate) + if err != nil { + log.Printf("insertChannel(%v, %v, %x) in channelsSynchronizeOnce error: %v", c.ChanId, c.ChannelPoint, nodeID, err) + continue + } + } + log.Printf("channelsSynchronizeOnce - done") + + return nil +} + +func (s *ForwardingHistorySync) ForwardingHistorySynchronize(ctx context.Context) { + for { + if ctx.Err() != nil { + return + } + + err := s.ForwardingHistorySynchronizeOnce() + log.Printf("forwardingHistorySynchronizeOnce() err: %v", err) + select { + case <-time.After(1 * time.Minute): + case <-ctx.Done(): + } + } +} + +func (s *ForwardingHistorySync) ForwardingHistorySynchronizeOnce() error { + last, err := s.forwardingStore.LastForwardingEvent() + if err != nil { + return fmt.Errorf("lastForwardingEvent() error: %w", err) + } + log.Printf("last1: %v", last) + last = last/1_000_000_000 - 1*3600 + if last <= 0 { + last = 1 + } + log.Printf("last2: %v", last) + now := time.Now() + endTime := uint64(now.Add(time.Hour * 24).Unix()) + indexOffset := uint32(0) + for { + forwardHistory, err := s.client.client.ForwardingHistory(context.Background(), &lnrpc.ForwardingHistoryRequest{ + StartTime: uint64(last), + EndTime: endTime, + NumMaxEvents: 10000, + IndexOffset: indexOffset, + }) + if err != nil { + log.Printf("ForwardingHistory error: %v", err) + return fmt.Errorf("client.ForwardingHistory() error: %w", err) + } + log.Printf("Offset: %v, Events: %v", indexOffset, len(forwardHistory.ForwardingEvents)) + if len(forwardHistory.ForwardingEvents) == 0 { + break + } + indexOffset = forwardHistory.LastOffsetIndex + cfe := copyFromEvents{events: forwardHistory.ForwardingEvents, idx: -1} + err = s.forwardingStore.InsertForwardingEvents(&cfe) + if err != nil { + log.Printf("insertForwardingEvents() error: %v", err) + return fmt.Errorf("insertForwardingEvents() error: %w", err) + } + } + return nil +} diff --git a/lnd/interceptor.go b/lnd/interceptor.go new file mode 100644 index 0000000..8d1574f --- /dev/null +++ b/lnd/interceptor.go @@ -0,0 +1,259 @@ +package lnd + +import ( + "bytes" + "context" + "log" + "sync" + "time" + + "github.com/breez/lspd/basetypes" + "github.com/breez/lspd/config" + "github.com/breez/lspd/interceptor" + "github.com/btcsuite/btcd/btcec/v2" + sphinx "github.com/lightningnetwork/lightning-onion" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/routerrpc" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/record" + "github.com/lightningnetwork/lnd/routing/route" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type LndHtlcInterceptor struct { + fwsync *ForwardingHistorySync + interceptor *interceptor.Interceptor + config *config.NodeConfig + client *LndClient + stopRequested bool + initWg sync.WaitGroup + doneWg sync.WaitGroup + ctx context.Context + cancel context.CancelFunc +} + +func NewLndHtlcInterceptor( + conf *config.NodeConfig, + client *LndClient, + fwsync *ForwardingHistorySync, + interceptor *interceptor.Interceptor, +) (*LndHtlcInterceptor, error) { + i := &LndHtlcInterceptor{ + config: conf, + client: client, + fwsync: fwsync, + interceptor: interceptor, + } + + i.initWg.Add(1) + + return i, nil +} + +func (i *LndHtlcInterceptor) Start() error { + ctx, cancel := context.WithCancel(context.Background()) + i.ctx = ctx + i.cancel = cancel + i.stopRequested = false + go i.fwsync.ForwardingHistorySynchronize(ctx) + go i.fwsync.ChannelsSynchronize(ctx) + + return i.intercept() +} + +func (i *LndHtlcInterceptor) Stop() error { + // Setting stopRequested to true will make the interceptor stop receiving. + i.stopRequested = true + + // Wait until all already received htlcs are handled, responses sent back. + i.doneWg.Wait() + + // Close the grpc connection. + i.cancel() + return nil +} + +func (i *LndHtlcInterceptor) WaitStarted() { + i.initWg.Wait() +} + +func (i *LndHtlcInterceptor) intercept() error { + inited := false + defer func() { + if !inited { + i.initWg.Done() + } + log.Printf("LND intercept(): stopping. Waiting for in-progress interceptions to complete.") + i.doneWg.Wait() + }() + + for { + if i.ctx.Err() != nil { + return i.ctx.Err() + } + + log.Printf("Connecting LND HTLC interceptor.") + interceptorClient, err := i.client.routerClient.HtlcInterceptor(i.ctx) + if err != nil { + log.Printf("routerClient.HtlcInterceptor(): %v", err) + <-time.After(time.Second) + continue + } + + for { + if i.ctx.Err() != nil { + return i.ctx.Err() + } + + if !inited { + inited = true + i.initWg.Done() + } + + // Stop receiving if stop if requested. The defer func on top of this + // function will assure all htlcs that are currently being processed + // will complete. + if i.stopRequested { + return nil + } + + request, err := interceptorClient.Recv() + if err != nil { + // If it is just the error result of the context cancellation + // the we exit silently. + status, ok := status.FromError(err) + if ok && status.Code() == codes.Canceled { + log.Printf("Got code canceled. Break.") + break + } + + // Otherwise it an unexpected error, we fail the test. + log.Printf("unexpected error in interceptor.Recv() %v", err) + break + } + + i.doneWg.Add(1) + go func() { + scid := basetypes.ShortChannelID(request.OutgoingRequestedChanId) + interceptResult := i.interceptor.Intercept(&scid, request.PaymentHash, request.OutgoingAmountMsat, request.OutgoingExpiry, request.IncomingExpiry) + switch interceptResult.Action { + case interceptor.INTERCEPT_RESUME_WITH_ONION: + onion, err := i.constructOnion(interceptResult, request.OutgoingExpiry, request.PaymentHash) + if err == nil { + interceptorClient.Send(&routerrpc.ForwardHtlcInterceptResponse{ + IncomingCircuitKey: request.IncomingCircuitKey, + Action: routerrpc.ResolveHoldForwardAction_RESUME, + OutgoingAmountMsat: interceptResult.AmountMsat, + OutgoingRequestedChanId: uint64(interceptResult.ChannelId), + OnionBlob: onion, + }) + } else { + interceptorClient.Send(&routerrpc.ForwardHtlcInterceptResponse{ + IncomingCircuitKey: request.IncomingCircuitKey, + Action: routerrpc.ResolveHoldForwardAction_FAIL, + FailureCode: lnrpc.Failure_TEMPORARY_CHANNEL_FAILURE, + }) + } + + case interceptor.INTERCEPT_FAIL_HTLC_WITH_CODE: + interceptorClient.Send(&routerrpc.ForwardHtlcInterceptResponse{ + IncomingCircuitKey: request.IncomingCircuitKey, + Action: routerrpc.ResolveHoldForwardAction_FAIL, + FailureCode: i.mapFailureCode(interceptResult.FailureCode), + }) + case interceptor.INTERCEPT_RESUME: + fallthrough + default: + interceptorClient.Send(&routerrpc.ForwardHtlcInterceptResponse{ + IncomingCircuitKey: request.IncomingCircuitKey, + Action: routerrpc.ResolveHoldForwardAction_RESUME, + OutgoingAmountMsat: request.OutgoingAmountMsat, + OutgoingRequestedChanId: request.OutgoingRequestedChanId, + OnionBlob: request.OnionBlob, + }) + } + + i.doneWg.Done() + }() + } + + <-time.After(time.Second) + } +} + +func (i *LndHtlcInterceptor) mapFailureCode(original interceptor.InterceptFailureCode) lnrpc.Failure_FailureCode { + switch original { + case interceptor.FAILURE_TEMPORARY_CHANNEL_FAILURE: + return lnrpc.Failure_TEMPORARY_CHANNEL_FAILURE + case interceptor.FAILURE_TEMPORARY_NODE_FAILURE: + return lnrpc.Failure_TEMPORARY_NODE_FAILURE + case interceptor.FAILURE_INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS: + return lnrpc.Failure_INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS + default: + log.Printf("Unknown failure code %v, default to temporary channel failure.", original) + return lnrpc.Failure_TEMPORARY_CHANNEL_FAILURE + } +} + +func (i *LndHtlcInterceptor) constructOnion( + interceptResult interceptor.InterceptResult, + reqOutgoingExpiry uint32, + reqPaymentHash []byte, +) ([]byte, error) { + pubKey, err := btcec.ParsePubKey(interceptResult.Destination) + if err != nil { + log.Printf("btcec.ParsePubKey(%x): %v", interceptResult.Destination, err) + return nil, err + } + + sessionKey, err := btcec.NewPrivateKey() + if err != nil { + log.Printf("btcec.NewPrivateKey(): %v", err) + return nil, err + } + + var addr [32]byte + copy(addr[:], interceptResult.PaymentSecret) + hop := route.Hop{ + AmtToForward: lnwire.MilliSatoshi(interceptResult.AmountMsat), + OutgoingTimeLock: reqOutgoingExpiry, + MPP: record.NewMPP(lnwire.MilliSatoshi(interceptResult.TotalAmountMsat), addr), + CustomRecords: make(record.CustomSet), + } + + var b bytes.Buffer + err = hop.PackHopPayload(&b, uint64(0)) + if err != nil { + log.Printf("hop.PackHopPayload(): %v", err) + return nil, err + } + + payload, err := sphinx.NewHopPayload(nil, b.Bytes()) + if err != nil { + log.Printf("sphinx.NewHopPayload(): %v", err) + return nil, err + } + + var sphinxPath sphinx.PaymentPath + sphinxPath[0] = sphinx.OnionHop{ + NodePub: *pubKey, + HopPayload: payload, + } + sphinxPacket, err := sphinx.NewOnionPacket( + &sphinxPath, sessionKey, reqPaymentHash, + sphinx.DeterministicPacketFiller, + ) + if err != nil { + log.Printf("sphinx.NewOnionPacket(): %v", err) + return nil, err + } + var onionBlob bytes.Buffer + err = sphinxPacket.Encode(&onionBlob) + if err != nil { + log.Printf("sphinxPacket.Encode(): %v", err) + return nil, err + } + + return onionBlob.Bytes(), nil +} diff --git a/lnd/macaroon_credential.go b/lnd/macaroon_credential.go new file mode 100644 index 0000000..f65ddaa --- /dev/null +++ b/lnd/macaroon_credential.go @@ -0,0 +1,25 @@ +package lnd + +import ( + "context" +) + +type MacaroonCredential struct { + MacaroonHex string +} + +func NewMacaroonCredential(hex string) *MacaroonCredential { + return &MacaroonCredential{ + MacaroonHex: hex, + } +} + +func (m *MacaroonCredential) RequireTransportSecurity() bool { + return true +} + +func (m *MacaroonCredential) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) { + md := make(map[string]string) + md["macaroon"] = m.MacaroonHex + return md, nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..2965738 --- /dev/null +++ b/main.go @@ -0,0 +1,185 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + "os/signal" + "strings" + "sync" + "syscall" + + "github.com/breez/lspd/chain" + "github.com/breez/lspd/cln" + "github.com/breez/lspd/config" + "github.com/breez/lspd/interceptor" + "github.com/breez/lspd/lnd" + "github.com/breez/lspd/mempool" + "github.com/breez/lspd/notifications" + "github.com/breez/lspd/postgresql" + "github.com/btcsuite/btcd/btcec/v2" +) + +func main() { + if len(os.Args) > 1 && os.Args[1] == "genkey" { + p, err := btcec.NewPrivateKey() + if err != nil { + log.Fatalf("btcec.NewPrivateKey() error: %v", err) + } + fmt.Printf("LSPD_PRIVATE_KEY=\"%x\"\n", p.Serialize()) + return + } + + n := os.Getenv("NODES") + var nodes []*config.NodeConfig + err := json.Unmarshal([]byte(n), &nodes) + if err != nil { + log.Fatalf("failed to unmarshal NODES env: %v", err) + } + + if len(nodes) == 0 { + log.Fatalf("need at least one node configured in NODES.") + } + + mempoolUrl := os.Getenv("MEMPOOL_API_BASE_URL") + if mempoolUrl == "" { + log.Fatalf("No mempool url configured.") + } + + feeEstimator, err := mempool.NewMempoolClient(mempoolUrl) + if err != nil { + log.Fatalf("failed to initialize mempool client: %v", err) + } + + var feeStrategy chain.FeeStrategy + envFeeStrategy := os.Getenv("MEMPOOL_PRIORITY") + switch strings.ToLower(envFeeStrategy) { + case "minimum": + feeStrategy = chain.FeeStrategyMinimum + case "economy": + feeStrategy = chain.FeeStrategyEconomy + case "hour": + feeStrategy = chain.FeeStrategyHour + case "halfhour": + feeStrategy = chain.FeeStrategyHalfHour + case "fastest": + feeStrategy = chain.FeeStrategyFastest + default: + feeStrategy = chain.FeeStrategyEconomy + } + log.Printf("using mempool api for fee estimation: %v, fee strategy: %v:%v", mempoolUrl, envFeeStrategy, feeStrategy) + + databaseUrl := os.Getenv("DATABASE_URL") + pool, err := postgresql.PgConnect(databaseUrl) + if err != nil { + log.Fatalf("pgConnect() error: %v", err) + } + + interceptStore := postgresql.NewPostgresInterceptStore(pool) + forwardingStore := postgresql.NewForwardingEventStore(pool) + notificationsStore := postgresql.NewNotificationsStore(pool) + notificationService := notifications.NewNotificationService(notificationsStore) + + var interceptors []interceptor.HtlcInterceptor + for _, node := range nodes { + var htlcInterceptor interceptor.HtlcInterceptor + if node.Lnd != nil { + client, err := lnd.NewLndClient(node.Lnd) + if err != nil { + log.Fatalf("failed to initialize LND client: %v", err) + } + + client.StartListeners() + fwsync := lnd.NewForwardingHistorySync(client, interceptStore, forwardingStore) + interceptor := interceptor.NewInterceptor(client, node, interceptStore, feeEstimator, feeStrategy, notificationService) + htlcInterceptor, err = lnd.NewLndHtlcInterceptor(node, client, fwsync, interceptor) + if err != nil { + log.Fatalf("failed to initialize LND interceptor: %v", err) + } + } + + if node.Cln != nil { + client, err := cln.NewClnClient(node.Cln.SocketPath) + if err != nil { + log.Fatalf("failed to initialize CLN client: %v", err) + } + + interceptor := interceptor.NewInterceptor(client, node, interceptStore, feeEstimator, feeStrategy, notificationService) + htlcInterceptor, err = cln.NewClnHtlcInterceptor(node, client, interceptor) + if err != nil { + log.Fatalf("failed to initialize CLN interceptor: %v", err) + } + } + + if htlcInterceptor == nil { + log.Fatalf("node has to be either cln or lnd") + } + + interceptors = append(interceptors, htlcInterceptor) + } + + address := os.Getenv("LISTEN_ADDRESS") + certMagicDomain := os.Getenv("CERTMAGIC_DOMAIN") + cs := NewChannelOpenerServer(interceptStore) + ns := notifications.NewNotificationsServer(notificationsStore) + s, err := NewGrpcServer(nodes, address, certMagicDomain, cs, ns) + if err != nil { + log.Fatalf("failed to initialize grpc server: %v", err) + } + + var wg sync.WaitGroup + wg.Add(len(interceptors) + 1) + + stopInterceptors := func() { + for _, interceptor := range interceptors { + interceptor.Stop() + } + } + + for _, interceptor := range interceptors { + i := interceptor + go func() { + err := i.Start() + if err == nil { + log.Printf("Interceptor stopped.") + } else { + log.Printf("FATAL. Interceptor stopped with error: %v", err) + } + + wg.Done() + + // If any interceptor stops, stop everything, so we're able to restart using systemd. + s.Stop() + stopInterceptors() + }() + } + + go func() { + err := s.Start() + if err == nil { + log.Printf("GRPC server stopped.") + } else { + log.Printf("FATAL. GRPC server stopped with error: %v", err) + } + + wg.Done() + + // If the server stops, stop everything else, so we're able to restart using systemd. + stopInterceptors() + }() + + c := make(chan os.Signal, 1) + signal.Notify(c, syscall.SIGINT, syscall.SIGTERM) + go func() { + sig := <-c + log.Printf("Received stop signal %v. Stopping.", sig) + + // Stop everything gracefully on stop signal + s.Stop() + stopInterceptors() + }() + + wg.Wait() + log.Printf("lspd exited") +} diff --git a/mempool/mempool_client.go b/mempool/mempool_client.go new file mode 100644 index 0000000..f24e747 --- /dev/null +++ b/mempool/mempool_client.go @@ -0,0 +1,90 @@ +package mempool + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/breez/lspd/chain" +) + +type MempoolClient struct { + apiBaseUrl string + httpClient *http.Client +} + +type RecommendedFeesResponse struct { + FastestFee float64 `json:"fastestFee"` + HalfHourFee float64 `json:"halfHourFee"` + HourFee float64 `json:"hourFee"` + EconomyFee float64 `json:"economyFee"` + MinimumFee float64 `json:"minimumFee"` +} + +func NewMempoolClient(apiBaseUrl string) (*MempoolClient, error) { + if apiBaseUrl == "" { + return nil, fmt.Errorf("apiBaseUrl not set") + } + + if !strings.HasSuffix(apiBaseUrl, "/") { + apiBaseUrl = apiBaseUrl + "/" + } + + return &MempoolClient{ + apiBaseUrl: apiBaseUrl, + httpClient: http.DefaultClient, + }, nil +} + +func (m *MempoolClient) EstimateFeeRate( + ctx context.Context, + strategy chain.FeeStrategy, +) (*chain.FeeEstimation, error) { + req, err := http.NewRequestWithContext( + ctx, + "GET", + m.apiBaseUrl+"fees/recommended", + nil, + ) + if err != nil { + return nil, fmt.Errorf("http.NewRequestWithContext error: %w", err) + } + + resp, err := m.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("httpClient.Do error: %w", err) + } + + defer resp.Body.Close() + if !(resp.StatusCode >= 200 && resp.StatusCode < 300) { + return nil, fmt.Errorf("error statuscode %v: %w", resp.StatusCode, err) + } + + var body RecommendedFeesResponse + err = json.NewDecoder(resp.Body).Decode(&body) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + var rate float64 + switch strategy { + case chain.FeeStrategyFastest: + rate = body.FastestFee + case chain.FeeStrategyHalfHour: + rate = body.HalfHourFee + case chain.FeeStrategyHour: + rate = body.HourFee + case chain.FeeStrategyEconomy: + rate = body.EconomyFee + case chain.FeeStrategyMinimum: + rate = body.MinimumFee + default: + return nil, fmt.Errorf("unsupported fee strategy: %v", strategy) + } + + return &chain.FeeEstimation{ + SatPerVByte: rate, + }, nil +} diff --git a/notifications/genproto.sh b/notifications/genproto.sh new file mode 100755 index 0000000..aff50da --- /dev/null +++ b/notifications/genproto.sh @@ -0,0 +1,4 @@ +#!/bin/bash +SCRIPTDIR=$(dirname $0) + +protoc --go_out=$SCRIPTDIR --go_opt=paths=source_relative --go-grpc_out=$SCRIPTDIR --go-grpc_opt=paths=source_relative -I=$SCRIPTDIR $SCRIPTDIR/*.proto diff --git a/notifications/notification_service.go b/notifications/notification_service.go new file mode 100644 index 0000000..9797374 --- /dev/null +++ b/notifications/notification_service.go @@ -0,0 +1,73 @@ +package notifications + +import ( + "bytes" + "context" + "encoding/json" + "log" + "net/http" +) + +type NotificationService struct { + store Store +} + +func NewNotificationService(store Store) *NotificationService { + return &NotificationService{ + store: store, + } +} + +type PaymentReceivedPayload struct { + Template string `json:"template" binding:"required,eq=payment_received"` + Data struct { + PaymentHash string `json:"payment_hash" binding:"required"` + } `json:"data"` +} + +func (s *NotificationService) Notify( + pubkey string, + paymenthash string, +) (bool, error) { + registrations, err := s.store.GetRegistrations(context.Background(), pubkey) + if err != nil { + log.Printf("Failed to get notification registrations for %s: %v", pubkey, err) + return false, err + } + + req := &PaymentReceivedPayload{ + Template: "payment_received", + Data: struct { + PaymentHash string "json:\"payment_hash\" binding:\"required\"" + }{ + PaymentHash: paymenthash, + }, + } + + var buf bytes.Buffer + err = json.NewEncoder(&buf).Encode(req) + if err != nil { + log.Printf("Failed to encode payment notification for %s: %v", pubkey, err) + return false, err + } + + notified := false + for _, r := range registrations { + resp, err := http.DefaultClient.Post(r, "application/json", &buf) + if err != nil { + log.Printf("Failed to send payment notification for %s to %s: %v", pubkey, r, err) + // TODO: Remove subscription? + continue + } + + if resp.StatusCode != 200 { + log.Printf("Got non 200 status code (%s) for payment notification for %s to %s: %v", resp.Status, pubkey, r, err) + // TODO: Remove subscription? + continue + } + + notified = true + } + + return notified, nil +} diff --git a/notifications/notifications.pb.go b/notifications/notifications.pb.go new file mode 100644 index 0000000..36db039 --- /dev/null +++ b/notifications/notifications.pb.go @@ -0,0 +1,218 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.28.1 +// protoc v3.21.12 +// source: notifications.proto + +package notifications + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type SubscribeNotificationsRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` + Signature []byte `protobuf:"bytes,2,opt,name=signature,proto3" json:"signature,omitempty"` +} + +func (x *SubscribeNotificationsRequest) Reset() { + *x = SubscribeNotificationsRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_notifications_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SubscribeNotificationsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubscribeNotificationsRequest) ProtoMessage() {} + +func (x *SubscribeNotificationsRequest) ProtoReflect() protoreflect.Message { + mi := &file_notifications_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubscribeNotificationsRequest.ProtoReflect.Descriptor instead. +func (*SubscribeNotificationsRequest) Descriptor() ([]byte, []int) { + return file_notifications_proto_rawDescGZIP(), []int{0} +} + +func (x *SubscribeNotificationsRequest) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +func (x *SubscribeNotificationsRequest) GetSignature() []byte { + if x != nil { + return x.Signature + } + return nil +} + +type SubscribeNotificationsReply struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *SubscribeNotificationsReply) Reset() { + *x = SubscribeNotificationsReply{} + if protoimpl.UnsafeEnabled { + mi := &file_notifications_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SubscribeNotificationsReply) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubscribeNotificationsReply) ProtoMessage() {} + +func (x *SubscribeNotificationsReply) ProtoReflect() protoreflect.Message { + mi := &file_notifications_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubscribeNotificationsReply.ProtoReflect.Descriptor instead. +func (*SubscribeNotificationsReply) Descriptor() ([]byte, []int) { + return file_notifications_proto_rawDescGZIP(), []int{1} +} + +var File_notifications_proto protoreflect.FileDescriptor + +var file_notifications_proto_rawDesc = []byte{ + 0x0a, 0x13, 0x6e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0d, 0x6e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x4f, 0x0a, 0x1d, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, + 0x65, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x61, + 0x74, 0x75, 0x72, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x73, 0x69, 0x67, 0x6e, + 0x61, 0x74, 0x75, 0x72, 0x65, 0x22, 0x1d, 0x0a, 0x1b, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, + 0x62, 0x65, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, + 0x65, 0x70, 0x6c, 0x79, 0x32, 0x85, 0x01, 0x0a, 0x0d, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x74, 0x0a, 0x16, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, + 0x69, 0x62, 0x65, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, + 0x12, 0x2c, 0x2e, 0x6e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, + 0x2e, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, + 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, + 0x2e, 0x6e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2e, 0x53, + 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x22, 0x00, 0x42, 0x25, 0x5a, 0x23, + 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x62, 0x72, 0x65, 0x65, 0x7a, + 0x2f, 0x6c, 0x73, 0x70, 0x64, 0x2f, 0x6e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_notifications_proto_rawDescOnce sync.Once + file_notifications_proto_rawDescData = file_notifications_proto_rawDesc +) + +func file_notifications_proto_rawDescGZIP() []byte { + file_notifications_proto_rawDescOnce.Do(func() { + file_notifications_proto_rawDescData = protoimpl.X.CompressGZIP(file_notifications_proto_rawDescData) + }) + return file_notifications_proto_rawDescData +} + +var file_notifications_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_notifications_proto_goTypes = []interface{}{ + (*SubscribeNotificationsRequest)(nil), // 0: notifications.SubscribeNotificationsRequest + (*SubscribeNotificationsReply)(nil), // 1: notifications.SubscribeNotificationsReply +} +var file_notifications_proto_depIdxs = []int32{ + 0, // 0: notifications.Notifications.SubscribeNotifications:input_type -> notifications.SubscribeNotificationsRequest + 1, // 1: notifications.Notifications.SubscribeNotifications:output_type -> notifications.SubscribeNotificationsReply + 1, // [1:2] is the sub-list for method output_type + 0, // [0:1] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_notifications_proto_init() } +func file_notifications_proto_init() { + if File_notifications_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_notifications_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SubscribeNotificationsRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_notifications_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SubscribeNotificationsReply); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_notifications_proto_rawDesc, + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_notifications_proto_goTypes, + DependencyIndexes: file_notifications_proto_depIdxs, + MessageInfos: file_notifications_proto_msgTypes, + }.Build() + File_notifications_proto = out.File + file_notifications_proto_rawDesc = nil + file_notifications_proto_goTypes = nil + file_notifications_proto_depIdxs = nil +} diff --git a/notifications/notifications.proto b/notifications/notifications.proto new file mode 100644 index 0000000..2590dff --- /dev/null +++ b/notifications/notifications.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; + +option go_package = "github.com/breez/lspd/notifications"; + +package notifications; + +service Notifications { + rpc SubscribeNotifications(SubscribeNotificationsRequest) + returns (SubscribeNotificationsReply) {} +} + +message SubscribeNotificationsRequest { + string url = 1; + bytes signature = 2; +} + +message SubscribeNotificationsReply { +} \ No newline at end of file diff --git a/notifications/notifications_grpc.pb.go b/notifications/notifications_grpc.pb.go new file mode 100644 index 0000000..13a2f77 --- /dev/null +++ b/notifications/notifications_grpc.pb.go @@ -0,0 +1,105 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.2.0 +// - protoc v3.21.12 +// source: notifications.proto + +package notifications + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.32.0 or later. +const _ = grpc.SupportPackageIsVersion7 + +// NotificationsClient is the client API for Notifications service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type NotificationsClient interface { + SubscribeNotifications(ctx context.Context, in *SubscribeNotificationsRequest, opts ...grpc.CallOption) (*SubscribeNotificationsReply, error) +} + +type notificationsClient struct { + cc grpc.ClientConnInterface +} + +func NewNotificationsClient(cc grpc.ClientConnInterface) NotificationsClient { + return ¬ificationsClient{cc} +} + +func (c *notificationsClient) SubscribeNotifications(ctx context.Context, in *SubscribeNotificationsRequest, opts ...grpc.CallOption) (*SubscribeNotificationsReply, error) { + out := new(SubscribeNotificationsReply) + err := c.cc.Invoke(ctx, "/notifications.Notifications/SubscribeNotifications", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// NotificationsServer is the server API for Notifications service. +// All implementations must embed UnimplementedNotificationsServer +// for forward compatibility +type NotificationsServer interface { + SubscribeNotifications(context.Context, *SubscribeNotificationsRequest) (*SubscribeNotificationsReply, error) + mustEmbedUnimplementedNotificationsServer() +} + +// UnimplementedNotificationsServer must be embedded to have forward compatible implementations. +type UnimplementedNotificationsServer struct { +} + +func (UnimplementedNotificationsServer) SubscribeNotifications(context.Context, *SubscribeNotificationsRequest) (*SubscribeNotificationsReply, error) { + return nil, status.Errorf(codes.Unimplemented, "method SubscribeNotifications not implemented") +} +func (UnimplementedNotificationsServer) mustEmbedUnimplementedNotificationsServer() {} + +// UnsafeNotificationsServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to NotificationsServer will +// result in compilation errors. +type UnsafeNotificationsServer interface { + mustEmbedUnimplementedNotificationsServer() +} + +func RegisterNotificationsServer(s grpc.ServiceRegistrar, srv NotificationsServer) { + s.RegisterService(&Notifications_ServiceDesc, srv) +} + +func _Notifications_SubscribeNotifications_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SubscribeNotificationsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(NotificationsServer).SubscribeNotifications(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/notifications.Notifications/SubscribeNotifications", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(NotificationsServer).SubscribeNotifications(ctx, req.(*SubscribeNotificationsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// Notifications_ServiceDesc is the grpc.ServiceDesc for Notifications service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var Notifications_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "notifications.Notifications", + HandlerType: (*NotificationsServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "SubscribeNotifications", + Handler: _Notifications_SubscribeNotifications_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "notifications.proto", +} diff --git a/notifications/server.go b/notifications/server.go new file mode 100644 index 0000000..ab73a18 --- /dev/null +++ b/notifications/server.go @@ -0,0 +1,58 @@ +package notifications + +import ( + context "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "log" + + "github.com/btcsuite/btcd/btcec/v2/ecdsa" +) + +var ErrInvalidSignature = fmt.Errorf("invalid signature") +var ErrInternal = fmt.Errorf("internal error") + +type server struct { + store Store + NotificationsServer +} + +func NewNotificationsServer(store Store) NotificationsServer { + return &server{ + store: store, + } +} + +func (s *server) SubscribeNotifications( + ctx context.Context, + request *SubscribeNotificationsRequest, +) (*SubscribeNotificationsReply, error) { + first := sha256.Sum256([]byte(request.Url)) + second := sha256.Sum256(first[:]) + pubkey, wasCompressed, err := ecdsa.RecoverCompact( + request.Signature, + second[:], + ) + if err != nil { + return nil, ErrInvalidSignature + } + + if !wasCompressed { + return nil, ErrInvalidSignature + } + + err = s.store.Register(ctx, hex.EncodeToString(pubkey.SerializeCompressed()), request.Url) + if err != nil { + log.Printf( + "failed to register %x for notifications on url %s: %v", + pubkey.SerializeCompressed(), + request.Url, + err, + ) + + return nil, ErrInternal + } + + return &SubscribeNotificationsReply{}, nil +} diff --git a/notifications/store.go b/notifications/store.go new file mode 100644 index 0000000..fb9a352 --- /dev/null +++ b/notifications/store.go @@ -0,0 +1,10 @@ +package notifications + +import ( + "context" +) + +type Store interface { + Register(ctx context.Context, pubkey string, url string) error + GetRegistrations(ctx context.Context, pubkey string) ([]string, error) +} diff --git a/postgresql/connect.go b/postgresql/connect.go new file mode 100644 index 0000000..b14942f --- /dev/null +++ b/postgresql/connect.go @@ -0,0 +1,17 @@ +package postgresql + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v4/pgxpool" +) + +func PgConnect(databaseUrl string) (*pgxpool.Pool, error) { + var err error + pgxPool, err := pgxpool.Connect(context.Background(), databaseUrl) + if err != nil { + return nil, fmt.Errorf("pgxpool.Connect(%v): %w", databaseUrl, err) + } + return pgxPool, nil +} diff --git a/postgresql/forwarding_event_store.go b/postgresql/forwarding_event_store.go new file mode 100644 index 0000000..42cda1c --- /dev/null +++ b/postgresql/forwarding_event_store.go @@ -0,0 +1,68 @@ +package postgresql + +import ( + "context" + "fmt" + "log" + + "github.com/breez/lspd/lnd" + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type ForwardingEventStore struct { + pool *pgxpool.Pool +} + +func NewForwardingEventStore(pool *pgxpool.Pool) *ForwardingEventStore { + return &ForwardingEventStore{pool: pool} +} + +func (s *ForwardingEventStore) LastForwardingEvent() (int64, error) { + var last int64 + err := s.pool.QueryRow(context.Background(), + `SELECT coalesce(MAX("timestamp"), 0) AS last FROM forwarding_history`).Scan(&last) + if err != nil { + return 0, err + } + return last, nil +} + +func (s *ForwardingEventStore) InsertForwardingEvents(rowSrc lnd.CopyFromSource) error { + + tx, err := s.pool.Begin(context.Background()) + if err != nil { + return fmt.Errorf("pgxPool.Begin() error: %w", err) + } + defer tx.Rollback(context.Background()) + + _, err = tx.Exec(context.Background(), ` + CREATE TEMP TABLE tmp_table ON COMMIT DROP AS + SELECT * + FROM forwarding_history + WITH NO DATA; + `) + if err != nil { + return fmt.Errorf("CREATE TEMP TABLE error: %w", err) + } + + count, err := tx.CopyFrom(context.Background(), + pgx.Identifier{"tmp_table"}, + []string{"timestamp", "chanid_in", "chanid_out", "amt_msat_in", "amt_msat_out"}, rowSrc) + if err != nil { + return fmt.Errorf("CopyFrom() error: %w", err) + } + log.Printf("count1: %v", count) + + cmdTag, err := tx.Exec(context.Background(), ` + INSERT INTO forwarding_history + SELECT * + FROM tmp_table + ON CONFLICT DO NOTHING + `) + if err != nil { + return fmt.Errorf("INSERT INTO forwarding_history error: %w", err) + } + log.Printf("count2: %v", cmdTag.RowsAffected()) + return tx.Commit(context.Background()) +} diff --git a/postgresql/intercept_store.go b/postgresql/intercept_store.go new file mode 100644 index 0000000..ebdea0c --- /dev/null +++ b/postgresql/intercept_store.go @@ -0,0 +1,162 @@ +package postgresql + +import ( + "context" + "encoding/json" + "fmt" + "log" + "time" + + "github.com/breez/lspd/basetypes" + "github.com/breez/lspd/interceptor" + "github.com/btcsuite/btcd/wire" + "github.com/jackc/pgtype" + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type extendedParams struct { + Token string `json:"token"` + Params interceptor.OpeningFeeParams `json:"fees_params"` +} + +type PostgresInterceptStore struct { + pool *pgxpool.Pool +} + +func NewPostgresInterceptStore(pool *pgxpool.Pool) *PostgresInterceptStore { + return &PostgresInterceptStore{pool: pool} +} + +func (s *PostgresInterceptStore) PaymentInfo(htlcPaymentHash []byte) (string, *interceptor.OpeningFeeParams, []byte, []byte, []byte, int64, int64, *wire.OutPoint, *string, error) { + var ( + p, tag *string + paymentHash, paymentSecret, destination []byte + incomingAmountMsat, outgoingAmountMsat int64 + fundingTxID []byte + fundingTxOutnum pgtype.Int4 + ) + err := s.pool.QueryRow(context.Background(), + `SELECT payment_hash, payment_secret, destination, incoming_amount_msat, outgoing_amount_msat, funding_tx_id, funding_tx_outnum, opening_fee_params, tag + FROM payments + WHERE payment_hash=$1 OR sha256('probing-01:' || payment_hash)=$1`, + htlcPaymentHash).Scan(&paymentHash, &paymentSecret, &destination, &incomingAmountMsat, &outgoingAmountMsat, &fundingTxID, &fundingTxOutnum, &p, &tag) + if err != nil { + if err == pgx.ErrNoRows { + err = nil + } + return "", nil, nil, nil, nil, 0, 0, nil, nil, err + } + + var cp *wire.OutPoint + if fundingTxID != nil { + cp, err = basetypes.NewOutPoint(fundingTxID, uint32(fundingTxOutnum.Int)) + if err != nil { + log.Printf("invalid funding txid in database %x", fundingTxID) + } + } + + var extParams *extendedParams + if p != nil { + err = json.Unmarshal([]byte(*p), &extParams) + if err != nil { + log.Printf("Failed to unmarshal OpeningFeeParams '%s': %v", *p, err) + return "", nil, nil, nil, nil, 0, 0, nil, nil, err + } + } + return extParams.Token, &extParams.Params, paymentHash, paymentSecret, destination, incomingAmountMsat, outgoingAmountMsat, cp, tag, nil +} + +func (s *PostgresInterceptStore) SetFundingTx(paymentHash []byte, channelPoint *wire.OutPoint) error { + commandTag, err := s.pool.Exec(context.Background(), + `UPDATE payments + SET funding_tx_id = $2, funding_tx_outnum = $3 + WHERE payment_hash=$1`, + paymentHash, channelPoint.Hash[:], channelPoint.Index) + log.Printf("setFundingTx(%x, %s, %d): %s err: %v", paymentHash, channelPoint.Hash.String(), channelPoint.Index, commandTag, err) + return err +} + +func (s *PostgresInterceptStore) RegisterPayment(token string, params *interceptor.OpeningFeeParams, destination, paymentHash, paymentSecret []byte, incomingAmountMsat, outgoingAmountMsat int64, tag string) error { + var t *string + if tag != "" { + t = &tag + } + + p := []byte{} + if params != nil { + var err error + p, err = json.Marshal(extendedParams{Token: token, Params: *params}) + if err != nil { + log.Printf("Failed to marshal OpeningFeeParams: %v", err) + return err + } + } + + commandTag, err := s.pool.Exec(context.Background(), + `INSERT INTO + payments (destination, payment_hash, payment_secret, incoming_amount_msat, outgoing_amount_msat, tag, opening_fee_params) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT DO NOTHING`, + destination, paymentHash, paymentSecret, incomingAmountMsat, outgoingAmountMsat, t, p) + log.Printf("registerPayment(%x, %x, %x, %v, %v, %v, %s) rows: %v err: %v", + destination, paymentHash, paymentSecret, incomingAmountMsat, outgoingAmountMsat, tag, p, commandTag.RowsAffected(), err) + if err != nil { + return fmt.Errorf("registerPayment(%x, %x, %x, %v, %v, %v, %s) error: %w", + destination, paymentHash, paymentSecret, incomingAmountMsat, outgoingAmountMsat, tag, p, err) + } + return nil +} + +func (s *PostgresInterceptStore) InsertChannel(initialChanID, confirmedChanId uint64, channelPoint string, nodeID []byte, lastUpdate time.Time) error { + + query := `INSERT INTO + channels (initial_chanid, confirmed_chanid, channel_point, nodeid, last_update) + VALUES ($1, NULLIF($2, 0::int8), $3, $4, $5) + ON CONFLICT (channel_point) DO UPDATE SET confirmed_chanid=NULLIF($2, 0::int8), last_update=$5` + + c, err := s.pool.Exec(context.Background(), + query, int64(initialChanID), int64(confirmedChanId), channelPoint, nodeID, lastUpdate) + if err != nil { + log.Printf("insertChannel(%v, %v, %s, %x) error: %v", + initialChanID, confirmedChanId, channelPoint, nodeID, err) + return fmt.Errorf("insertChannel(%v, %v, %s, %x) error: %w", + initialChanID, confirmedChanId, channelPoint, nodeID, err) + } + log.Printf("insertChannel(%v, %v, %x) result: %v", + initialChanID, confirmedChanId, nodeID, c.String()) + return nil +} + +func (s *PostgresInterceptStore) GetFeeParamsSettings(token string) ([]*interceptor.OpeningFeeParamsSetting, error) { + rows, err := s.pool.Query(context.Background(), `SELECT validity, params FROM new_channel_params WHERE token=$1`, token) + if err != nil { + log.Printf("GetFeeParamsSettings(%v) error: %v", token, err) + return nil, err + } + + var settings []*interceptor.OpeningFeeParamsSetting + for rows.Next() { + var validity int64 + var param string + err = rows.Scan(&validity, ¶m) + if err != nil { + return nil, err + } + + var params *interceptor.OpeningFeeParams + err := json.Unmarshal([]byte(param), ¶ms) + if err != nil { + log.Printf("Failed to unmarshal fee param '%v': %v", param, err) + return nil, err + } + + duration := time.Second * time.Duration(validity) + settings = append(settings, &interceptor.OpeningFeeParamsSetting{ + Validity: duration, + Params: params, + }) + } + + return settings, nil +} diff --git a/postgresql/migrations/000000_.down.sql b/postgresql/migrations/000000_.down.sql new file mode 100644 index 0000000..e69de29 diff --git a/postgresql/migrations/000000_.up.sql b/postgresql/migrations/000000_.up.sql new file mode 100644 index 0000000..e69de29 diff --git a/postgresql/migrations/000001_create_payments_table.down.sql b/postgresql/migrations/000001_create_payments_table.down.sql new file mode 100644 index 0000000..8baef0d --- /dev/null +++ b/postgresql/migrations/000001_create_payments_table.down.sql @@ -0,0 +1 @@ +DROP TABLE public.payments; \ No newline at end of file diff --git a/postgresql/migrations/000001_create_payments_table.up.sql b/postgresql/migrations/000001_create_payments_table.up.sql new file mode 100644 index 0000000..19cbdeb --- /dev/null +++ b/postgresql/migrations/000001_create_payments_table.up.sql @@ -0,0 +1,5 @@ +CREATE TABLE public.payments ( + payment_hash bytea NOT NULL, + payment_request_out varchar NOT NULL, + CONSTRAINT payments_pkey PRIMARY KEY (payment_hash) +); \ No newline at end of file diff --git a/postgresql/migrations/000002_details.down.sql b/postgresql/migrations/000002_details.down.sql new file mode 100644 index 0000000..b559ed4 --- /dev/null +++ b/postgresql/migrations/000002_details.down.sql @@ -0,0 +1,5 @@ +ALTER TABLE public.payments DROP COLUMN payment_secret; +ALTER TABLE public.payments DROP COLUMN destination; +ALTER TABLE public.payments DROP COLUMN incoming_amount_msat; +ALTER TABLE public.payments DROP COLUMN outgoing_amount_msat; +ALTER TABLE public.payments ADD payment_request_out varchar NOT NULL; \ No newline at end of file diff --git a/postgresql/migrations/000002_details.up.sql b/postgresql/migrations/000002_details.up.sql new file mode 100644 index 0000000..68d95fa --- /dev/null +++ b/postgresql/migrations/000002_details.up.sql @@ -0,0 +1,5 @@ +ALTER TABLE public.payments DROP COLUMN payment_request_out; +ALTER TABLE public.payments ADD payment_secret bytea NOT NULL; +ALTER TABLE public.payments ADD destination bytea NOT NULL; +ALTER TABLE public.payments ADD incoming_amount_msat bigint NOT NULL; +ALTER TABLE public.payments ADD outgoing_amount_msat bigint NOT NULL; \ No newline at end of file diff --git a/postgresql/migrations/000003_funding_tx.down.sql b/postgresql/migrations/000003_funding_tx.down.sql new file mode 100644 index 0000000..c6da088 --- /dev/null +++ b/postgresql/migrations/000003_funding_tx.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.payments DROP COLUMN funding_tx_id; +ALTER TABLE public.payments DROP COLUMN funding_tx_outnum; \ No newline at end of file diff --git a/postgresql/migrations/000003_funding_tx.up.sql b/postgresql/migrations/000003_funding_tx.up.sql new file mode 100644 index 0000000..df40ee6 --- /dev/null +++ b/postgresql/migrations/000003_funding_tx.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.payments ADD funding_tx_id bytea NULL; +ALTER TABLE public.payments ADD funding_tx_outnum int NULL; \ No newline at end of file diff --git a/postgresql/migrations/000004_create_probe_payments_hash_index.down.sql b/postgresql/migrations/000004_create_probe_payments_hash_index.down.sql new file mode 100644 index 0000000..394ca43 --- /dev/null +++ b/postgresql/migrations/000004_create_probe_payments_hash_index.down.sql @@ -0,0 +1 @@ +DROP INDEX probe_payment_hash; \ No newline at end of file diff --git a/postgresql/migrations/000004_create_probe_payments_hash_index.up.sql b/postgresql/migrations/000004_create_probe_payments_hash_index.up.sql new file mode 100644 index 0000000..cef78e3 --- /dev/null +++ b/postgresql/migrations/000004_create_probe_payments_hash_index.up.sql @@ -0,0 +1 @@ +CREATE INDEX probe_payment_hash ON public.payments (sha256('probing-01:' || payment_hash)); \ No newline at end of file diff --git a/postgresql/migrations/000005_forwarding_history.down.sql b/postgresql/migrations/000005_forwarding_history.down.sql new file mode 100644 index 0000000..4dcb607 --- /dev/null +++ b/postgresql/migrations/000005_forwarding_history.down.sql @@ -0,0 +1 @@ +DROP TABLE public.forwarding_history; \ No newline at end of file diff --git a/postgresql/migrations/000005_forwarding_history.up.sql b/postgresql/migrations/000005_forwarding_history.up.sql new file mode 100644 index 0000000..0762b1a --- /dev/null +++ b/postgresql/migrations/000005_forwarding_history.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE public.forwarding_history ( + "timestamp" bigint NOT NULL, + chanid_in bigint NOT NULL, + chanid_out bigint NOT NULL, + amt_msat_in bigint NOT NULL, + amt_msat_out bigint NOT NULL, + CONSTRAINT timestamp_pkey PRIMARY KEY ("timestamp") +); +CREATE INDEX forwarding_history_chanid_in_idx ON public.forwarding_history (chanid_in); +CREATE INDEX forwarding_history_chanid_out_idx ON public.forwarding_history (chanid_out); \ No newline at end of file diff --git a/postgresql/migrations/000006_channels.down.sql b/postgresql/migrations/000006_channels.down.sql new file mode 100644 index 0000000..14a4bcc --- /dev/null +++ b/postgresql/migrations/000006_channels.down.sql @@ -0,0 +1 @@ +DROP TABLE public.channels; \ No newline at end of file diff --git a/postgresql/migrations/000006_channels.up.sql b/postgresql/migrations/000006_channels.up.sql new file mode 100644 index 0000000..659a565 --- /dev/null +++ b/postgresql/migrations/000006_channels.up.sql @@ -0,0 +1,7 @@ +CREATE TABLE public.channels ( + chanid bigint NOT NULL, + channel_point varchar NULL, + nodeid bytea NULL, + CONSTRAINT chanid_pkey PRIMARY KEY (chanid) +); +CREATE INDEX channels_nodeid_idx ON public.channels (nodeid); diff --git a/postgresql/migrations/000007_channels_last_update.down.sql b/postgresql/migrations/000007_channels_last_update.down.sql new file mode 100644 index 0000000..4442e51 --- /dev/null +++ b/postgresql/migrations/000007_channels_last_update.down.sql @@ -0,0 +1 @@ +ALTER TABLE public.channels DROP COLUMN last_update; \ No newline at end of file diff --git a/postgresql/migrations/000007_channels_last_update.up.sql b/postgresql/migrations/000007_channels_last_update.up.sql new file mode 100644 index 0000000..83db788 --- /dev/null +++ b/postgresql/migrations/000007_channels_last_update.up.sql @@ -0,0 +1 @@ +ALTER TABLE public.channels ADD COLUMN last_update TIMESTAMP; \ No newline at end of file diff --git a/postgresql/migrations/000008_one_record_per_channel.down.sql b/postgresql/migrations/000008_one_record_per_channel.down.sql new file mode 100644 index 0000000..4ba53f6 --- /dev/null +++ b/postgresql/migrations/000008_one_record_per_channel.down.sql @@ -0,0 +1,21 @@ +ALTER INDEX public.channels_nodeid_idx RENAME TO channels_new_nodeid_idx; +ALTER INDEX public.channels_channel_point_pkey RENAME TO channels_new_channel_point_pkey +ALTER TABLE public.channels RENAME TO channels_new; + +CREATE TABLE public.channels ( + chanid int8 NOT NULL, + channel_point varchar NULL, + nodeid bytea NULL, + last_update timestamp NULL, + CONSTRAINT chanid_pkey PRIMARY KEY (chanid) +); +CREATE INDEX channels_nodeid_idx ON public.channels USING btree (nodeid); + +INSERT INTO public.channels +SELECT initial_chanid chanid, channel_point, nodeid, last_update FROM channels_new; + +INSERT INTO public.channels +SELECT confirmed_chanid chanid, channel_point, nodeid, last_update FROM channels_new + WHERE confirmed_chanid IS NOT NULL AND confirmed_chanid <> initial_chanid; + +DROP TABLE channels_new; diff --git a/postgresql/migrations/000008_one_record_per_channel.up.sql b/postgresql/migrations/000008_one_record_per_channel.up.sql new file mode 100644 index 0000000..2261a23 --- /dev/null +++ b/postgresql/migrations/000008_one_record_per_channel.up.sql @@ -0,0 +1,22 @@ +ALTER INDEX public.channels_nodeid_idx RENAME TO channels_old_nodeid_idx; +ALTER INDEX public.chanid_pkey RENAME TO channels_old_chanid_pkey; +ALTER TABLE public.channels RENAME TO channels_old; + +CREATE TABLE public.channels ( + initial_chanid int8 NOT NULL, + confirmed_chanid int8 NULL, + channel_point varchar NOT NULL, + nodeid bytea NOT NULL, + last_update timestamp NULL, + CONSTRAINT channels_channel_point_pkey PRIMARY KEY (channel_point) +); +CREATE INDEX channels_nodeid_idx ON public.channels USING btree (nodeid); + +INSERT INTO public.channels +SELECT + min(chanid) initial_chanid, + CASE WHEN (max(chanid) >> 40) < (3 << 17) THEN NULL ELSE max(chanid) END confirmed_chanid, + channel_point, nodeid, max(last_update) last_update +FROM channels_old GROUP BY channel_point, nodeid; + +DROP TABLE public.channels_old; diff --git a/postgresql/migrations/000009_register_payment_tag.down.sql b/postgresql/migrations/000009_register_payment_tag.down.sql new file mode 100644 index 0000000..a41df51 --- /dev/null +++ b/postgresql/migrations/000009_register_payment_tag.down.sql @@ -0,0 +1 @@ +ALTER TABLE public.payments DROP COLUMN tag; diff --git a/postgresql/migrations/000009_register_payment_tag.up.sql b/postgresql/migrations/000009_register_payment_tag.up.sql new file mode 100644 index 0000000..89f004a --- /dev/null +++ b/postgresql/migrations/000009_register_payment_tag.up.sql @@ -0,0 +1 @@ +ALTER TABLE public.payments ADD tag jsonb NULL; diff --git a/postgresql/migrations/000010_opening_fee_params.down.sql b/postgresql/migrations/000010_opening_fee_params.down.sql new file mode 100644 index 0000000..20173eb --- /dev/null +++ b/postgresql/migrations/000010_opening_fee_params.down.sql @@ -0,0 +1 @@ +ALTER TABLE public.payments DROP COLUMN opening_fee_params; diff --git a/postgresql/migrations/000010_opening_fee_params.up.sql b/postgresql/migrations/000010_opening_fee_params.up.sql new file mode 100644 index 0000000..0efe06c --- /dev/null +++ b/postgresql/migrations/000010_opening_fee_params.up.sql @@ -0,0 +1 @@ +ALTER TABLE public.payments ADD opening_fee_params jsonb NULL; diff --git a/postgresql/migrations/000011_new_channel_params.down.sql b/postgresql/migrations/000011_new_channel_params.down.sql new file mode 100644 index 0000000..3ae494c --- /dev/null +++ b/postgresql/migrations/000011_new_channel_params.down.sql @@ -0,0 +1 @@ +DROP TABLE public.new_channel_params; diff --git a/postgresql/migrations/000011_new_channel_params.up.sql b/postgresql/migrations/000011_new_channel_params.up.sql new file mode 100644 index 0000000..126de8b --- /dev/null +++ b/postgresql/migrations/000011_new_channel_params.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE public.new_channel_params ( + validity int NOT NULL, + params jsonb NOT NULL +); +CREATE UNIQUE INDEX new_channel_params_validity_idx ON public.new_channel_params (validity); + +INSERT INTO public.new_channel_params (validity, params) + VALUES(259200, '{"min_msat": "12000000", "proportional": 7500, "max_idle_time": 4320, "max_client_to_self_delay": 432}'::jsonb); + +INSERT INTO public.new_channel_params (validity, params) + VALUES(3600, '{"min_msat": "10000000", "proportional": 7500, "max_idle_time": 4320, "max_client_to_self_delay": 432}'::jsonb); diff --git a/postgresql/migrations/000012_new_channel_params_token.down.sql b/postgresql/migrations/000012_new_channel_params_token.down.sql new file mode 100644 index 0000000..b6f6e5b --- /dev/null +++ b/postgresql/migrations/000012_new_channel_params_token.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE public.new_channel_params DROP COLUMN token; +DROP INDEX new_channel_params_token_validity_idx; +CREATE UNIQUE INDEX new_channel_params_validity_idx ON public.new_channel_params (validity); \ No newline at end of file diff --git a/postgresql/migrations/000012_new_channel_params_token.up.sql b/postgresql/migrations/000012_new_channel_params_token.up.sql new file mode 100644 index 0000000..0236c1f --- /dev/null +++ b/postgresql/migrations/000012_new_channel_params_token.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE public.new_channel_params ADD token varchar; +DROP INDEX public.new_channel_params_validity_idx; +CREATE UNIQUE INDEX new_channel_params_token_validity_idx ON public.new_channel_params (token, validity); \ No newline at end of file diff --git a/postgresql/migrations/000013_notification_subscription.down.sql b/postgresql/migrations/000013_notification_subscription.down.sql new file mode 100644 index 0000000..994d8c3 --- /dev/null +++ b/postgresql/migrations/000013_notification_subscription.down.sql @@ -0,0 +1,3 @@ +DROP INDEX notification_subscriptions_pubkey_url_key; +DROP INDEX notification_subscriptions_pubkey_idx; +DROP TABLE public.notification_subscriptions; diff --git a/postgresql/migrations/000013_notification_subscription.up.sql b/postgresql/migrations/000013_notification_subscription.up.sql new file mode 100644 index 0000000..d19cf2a --- /dev/null +++ b/postgresql/migrations/000013_notification_subscription.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE public.notification_subscriptions ( + id bigserial primary key, + pubkey bytea NOT NULL, + url varchar NOT NULL, + created_at bigint NOT NULL, + refreshed_at bigint NOT NULL +); + +CREATE INDEX notification_subscriptions_pubkey_idx ON public.notification_subscriptions (pubkey); +CREATE UNIQUE INDEX notification_subscriptions_pubkey_url_key ON public.notification_subscriptions (pubkey, url); diff --git a/postgresql/notifications_store.go b/postgresql/notifications_store.go new file mode 100644 index 0000000..b0675a8 --- /dev/null +++ b/postgresql/notifications_store.go @@ -0,0 +1,76 @@ +package postgresql + +import ( + "context" + "encoding/hex" + "time" + + "github.com/jackc/pgx/v4/pgxpool" +) + +type NotificationsStore struct { + pool *pgxpool.Pool +} + +func NewNotificationsStore(pool *pgxpool.Pool) *NotificationsStore { + return &NotificationsStore{pool: pool} +} + +func (s *NotificationsStore) Register( + ctx context.Context, + pubkey string, + url string, +) error { + pk, err := hex.DecodeString(pubkey) + if err != nil { + return err + } + + now := time.Now().UnixMicro() + _, err = s.pool.Exec( + ctx, + `INSERT INTO public.notification_subscriptions (pubkey, url, created_at, refreshed_at) + values ($1, $2, $3, $4) + ON CONFLICT (pubkey, url) DO UPDATE SET refreshed_at = $4`, + pk, + url, + now, + now, + ) + + return err +} + +func (s *NotificationsStore) GetRegistrations( + ctx context.Context, + pubkey string, +) ([]string, error) { + pk, err := hex.DecodeString(pubkey) + if err != nil { + return nil, err + } + + rows, err := s.pool.Query( + ctx, + `SELECT url + FROM public.notification_subscriptions + WHERE pubkey = $1`, + pk, + ) + if err != nil { + return nil, err + } + + var result []string + for rows.Next() { + var url string + err = rows.Scan(&url) + if err != nil { + return nil, err + } + + result = append(result, url) + } + + return result, nil +} diff --git a/rpc/genproto.sh b/rpc/genproto.sh new file mode 100755 index 0000000..c3bedff --- /dev/null +++ b/rpc/genproto.sh @@ -0,0 +1,4 @@ +#!/bin/bash +SCRIPTDIR=$(dirname $0) + +protoc --go_out=$SCRIPTDIR --go_opt=paths=source_relative --go-grpc_out=$SCRIPTDIR --go-grpc_opt=paths=source_relative -I=$SCRIPTDIR $SCRIPTDIR/*.proto \ No newline at end of file diff --git a/rpc/lspd.md b/rpc/lspd.md new file mode 100644 index 0000000..6e3b38f --- /dev/null +++ b/rpc/lspd.md @@ -0,0 +1,186 @@ +# Protocol Documentation + + +## Table of Contents + +- [lspd.proto](#lspd.proto) + - [ChannelInformationReply](#lspd.ChannelInformationReply) + - [ChannelInformationRequest](#lspd.ChannelInformationRequest) + - [OpenChannelReply](#lspd.OpenChannelReply) + - [OpenChannelRequest](#lspd.OpenChannelRequest) + - [PaymentInformation](#lspd.PaymentInformation) + - [RegisterPaymentReply](#lspd.RegisterPaymentReply) + - [RegisterPaymentRequest](#lspd.RegisterPaymentRequest) + + + + - [ChannelOpener](#lspd.ChannelOpener) + + +- [Scalar Value Types](#scalar-value-types) + + + + +

Top

+ +## lspd.proto + + + + + +### ChannelInformationReply + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| name | [string](#string) | | The name of of lsp | +| pubkey | [string](#string) | | The identity pubkey of the Lightning node | +| host | [string](#string) | | The network location of the lightning node, e.g. `12.34.56.78:9012` or / `localhost:10011` | +| channel_capacity | [int64](#int64) | | The channel capacity in satoshis | +| target_conf | [int32](#int32) | | The target number of blocks that the funding transaction should be / confirmed by. | +| base_fee_msat | [int64](#int64) | | The base fee charged regardless of the number of milli-satoshis sent. | +| fee_rate | [double](#double) | | The effective fee rate in milli-satoshis. The precision of this value goes / up to 6 decimal places, so 1e-6. | +| time_lock_delta | [uint32](#uint32) | | The required timelock delta for HTLCs forwarded over the channel. | +| min_htlc_msat | [int64](#int64) | | The minimum value in millisatoshi we will require for incoming HTLCs on / the channel. | +| channel_fee_permyriad | [int64](#int64) | | | +| lsp_pubkey | [bytes](#bytes) | | | + + + + + + + + +### ChannelInformationRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| pubkey | [string](#string) | | The identity pubkey of the Lightning node | + + + + + + + + +### OpenChannelReply + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| tx_hash | [string](#string) | | The transaction hash | +| output_index | [uint32](#uint32) | | The output index | + + + + + + + + +### OpenChannelRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| pubkey | [string](#string) | | The identity pubkey of the Lightning node | + + + + + + + + +### PaymentInformation + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| payment_hash | [bytes](#bytes) | | | +| payment_secret | [bytes](#bytes) | | | +| destination | [bytes](#bytes) | | | +| incoming_amount_msat | [int64](#int64) | | | +| outgoing_amount_msat | [int64](#int64) | | | + + + + + + + + +### RegisterPaymentReply + + + + + + + + + +### RegisterPaymentRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| blob | [bytes](#bytes) | | | + + + + + + + + + + + + + + +### ChannelOpener + + +| Method Name | Request Type | Response Type | Description | +| ----------- | ------------ | ------------- | ------------| +| ChannelInformation | [ChannelInformationRequest](#lspd.ChannelInformationRequest) | [ChannelInformationReply](#lspd.ChannelInformationReply) | | +| OpenChannel | [OpenChannelRequest](#lspd.OpenChannelRequest) | [OpenChannelReply](#lspd.OpenChannelReply) | | +| RegisterPayment | [RegisterPaymentRequest](#lspd.RegisterPaymentRequest) | [RegisterPaymentReply](#lspd.RegisterPaymentReply) | | + + + + + +## Scalar Value Types + +| .proto Type | Notes | C++ Type | Java Type | Python Type | +| ----------- | ----- | -------- | --------- | ----------- | +| double | | double | double | float | +| float | | float | float | float | +| int32 | Uses variable-length encoding. Inefficient for encoding negative numbers – if your field is likely to have negative values, use sint32 instead. | int32 | int | int | +| int64 | Uses variable-length encoding. Inefficient for encoding negative numbers – if your field is likely to have negative values, use sint64 instead. | int64 | long | int/long | +| uint32 | Uses variable-length encoding. | uint32 | int | int/long | +| uint64 | Uses variable-length encoding. | uint64 | long | int/long | +| sint32 | Uses variable-length encoding. Signed int value. These more efficiently encode negative numbers than regular int32s. | int32 | int | int | +| sint64 | Uses variable-length encoding. Signed int value. These more efficiently encode negative numbers than regular int64s. | int64 | long | int/long | +| fixed32 | Always four bytes. More efficient than uint32 if values are often greater than 2^28. | uint32 | int | int | +| fixed64 | Always eight bytes. More efficient than uint64 if values are often greater than 2^56. | uint64 | long | int/long | +| sfixed32 | Always four bytes. | int32 | int | int | +| sfixed64 | Always eight bytes. | int64 | long | int/long | +| bool | | bool | boolean | boolean | +| string | A string must always contain UTF-8 encoded or 7-bit ASCII text. | string | String | str/unicode | +| bytes | May contain any arbitrary sequence of bytes. | string | ByteString | str | + diff --git a/rpc/lspd.pb.go b/rpc/lspd.pb.go new file mode 100644 index 0000000..e2a0b94 --- /dev/null +++ b/rpc/lspd.pb.go @@ -0,0 +1,1237 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.28.1 +// protoc v3.21.12 +// source: lspd.proto + +package lspd + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type ChannelInformationRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + /// The identity pubkey of the Lightning node + Pubkey string `protobuf:"bytes,1,opt,name=pubkey,proto3" json:"pubkey,omitempty"` +} + +func (x *ChannelInformationRequest) Reset() { + *x = ChannelInformationRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_lspd_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ChannelInformationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChannelInformationRequest) ProtoMessage() {} + +func (x *ChannelInformationRequest) ProtoReflect() protoreflect.Message { + mi := &file_lspd_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ChannelInformationRequest.ProtoReflect.Descriptor instead. +func (*ChannelInformationRequest) Descriptor() ([]byte, []int) { + return file_lspd_proto_rawDescGZIP(), []int{0} +} + +func (x *ChannelInformationRequest) GetPubkey() string { + if x != nil { + return x.Pubkey + } + return "" +} + +type ChannelInformationReply struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + /// The name of of lsp + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + /// The identity pubkey of the Lightning node + Pubkey string `protobuf:"bytes,2,opt,name=pubkey,proto3" json:"pubkey,omitempty"` + /// The network location of the lightning node, e.g. `12.34.56.78:9012` or + /// `localhost:10011` + Host string `protobuf:"bytes,3,opt,name=host,proto3" json:"host,omitempty"` + /// The channel capacity in satoshis + ChannelCapacity int64 `protobuf:"varint,4,opt,name=channel_capacity,proto3" json:"channel_capacity,omitempty"` + /// The target number of blocks that the funding transaction should be + /// confirmed by. + TargetConf int32 `protobuf:"varint,5,opt,name=target_conf,proto3" json:"target_conf,omitempty"` + /// The base fee charged regardless of the number of milli-satoshis sent. + BaseFeeMsat int64 `protobuf:"varint,6,opt,name=base_fee_msat,proto3" json:"base_fee_msat,omitempty"` + /// The effective fee rate in milli-satoshis. The precision of this value goes + /// up to 6 decimal places, so 1e-6. + FeeRate float64 `protobuf:"fixed64,7,opt,name=fee_rate,proto3" json:"fee_rate,omitempty"` + /// The required timelock delta for HTLCs forwarded over the channel. + TimeLockDelta uint32 `protobuf:"varint,8,opt,name=time_lock_delta,proto3" json:"time_lock_delta,omitempty"` + /// The minimum value in millisatoshi we will require for incoming HTLCs on + /// the channel. + MinHtlcMsat int64 `protobuf:"varint,9,opt,name=min_htlc_msat,proto3" json:"min_htlc_msat,omitempty"` + // Deprecated: Do not use. + ChannelFeePermyriad int64 `protobuf:"varint,10,opt,name=channel_fee_permyriad,json=channelFeePermyriad,proto3" json:"channel_fee_permyriad,omitempty"` + LspPubkey []byte `protobuf:"bytes,11,opt,name=lsp_pubkey,json=lspPubkey,proto3" json:"lsp_pubkey,omitempty"` + // The channel can be closed if not used this duration in seconds. + // + // Deprecated: Do not use. + MaxInactiveDuration int64 `protobuf:"varint,12,opt,name=max_inactive_duration,json=maxInactiveDuration,proto3" json:"max_inactive_duration,omitempty"` + // Deprecated: Do not use. + ChannelMinimumFeeMsat int64 `protobuf:"varint,13,opt,name=channel_minimum_fee_msat,json=channelMinimumFeeMsat,proto3" json:"channel_minimum_fee_msat,omitempty"` + OpeningFeeParamsMenu []*OpeningFeeParams `protobuf:"bytes,14,rep,name=opening_fee_params_menu,json=openingFeeParamsMenu,proto3" json:"opening_fee_params_menu,omitempty"` +} + +func (x *ChannelInformationReply) Reset() { + *x = ChannelInformationReply{} + if protoimpl.UnsafeEnabled { + mi := &file_lspd_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ChannelInformationReply) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChannelInformationReply) ProtoMessage() {} + +func (x *ChannelInformationReply) ProtoReflect() protoreflect.Message { + mi := &file_lspd_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ChannelInformationReply.ProtoReflect.Descriptor instead. +func (*ChannelInformationReply) Descriptor() ([]byte, []int) { + return file_lspd_proto_rawDescGZIP(), []int{1} +} + +func (x *ChannelInformationReply) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ChannelInformationReply) GetPubkey() string { + if x != nil { + return x.Pubkey + } + return "" +} + +func (x *ChannelInformationReply) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +func (x *ChannelInformationReply) GetChannelCapacity() int64 { + if x != nil { + return x.ChannelCapacity + } + return 0 +} + +func (x *ChannelInformationReply) GetTargetConf() int32 { + if x != nil { + return x.TargetConf + } + return 0 +} + +func (x *ChannelInformationReply) GetBaseFeeMsat() int64 { + if x != nil { + return x.BaseFeeMsat + } + return 0 +} + +func (x *ChannelInformationReply) GetFeeRate() float64 { + if x != nil { + return x.FeeRate + } + return 0 +} + +func (x *ChannelInformationReply) GetTimeLockDelta() uint32 { + if x != nil { + return x.TimeLockDelta + } + return 0 +} + +func (x *ChannelInformationReply) GetMinHtlcMsat() int64 { + if x != nil { + return x.MinHtlcMsat + } + return 0 +} + +// Deprecated: Do not use. +func (x *ChannelInformationReply) GetChannelFeePermyriad() int64 { + if x != nil { + return x.ChannelFeePermyriad + } + return 0 +} + +func (x *ChannelInformationReply) GetLspPubkey() []byte { + if x != nil { + return x.LspPubkey + } + return nil +} + +// Deprecated: Do not use. +func (x *ChannelInformationReply) GetMaxInactiveDuration() int64 { + if x != nil { + return x.MaxInactiveDuration + } + return 0 +} + +// Deprecated: Do not use. +func (x *ChannelInformationReply) GetChannelMinimumFeeMsat() int64 { + if x != nil { + return x.ChannelMinimumFeeMsat + } + return 0 +} + +func (x *ChannelInformationReply) GetOpeningFeeParamsMenu() []*OpeningFeeParams { + if x != nil { + return x.OpeningFeeParamsMenu + } + return nil +} + +type OpeningFeeParams struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + MinMsat uint64 `protobuf:"varint,1,opt,name=min_msat,json=minMsat,proto3" json:"min_msat,omitempty"` + Proportional uint32 `protobuf:"varint,2,opt,name=proportional,proto3" json:"proportional,omitempty"` + ValidUntil string `protobuf:"bytes,3,opt,name=valid_until,json=validUntil,proto3" json:"valid_until,omitempty"` + // The channel can be closed if not used this duration in blocks. + MaxIdleTime uint32 `protobuf:"varint,4,opt,name=max_idle_time,json=maxIdleTime,proto3" json:"max_idle_time,omitempty"` + MaxClientToSelfDelay uint32 `protobuf:"varint,5,opt,name=max_client_to_self_delay,json=maxClientToSelfDelay,proto3" json:"max_client_to_self_delay,omitempty"` + Promise string `protobuf:"bytes,6,opt,name=promise,proto3" json:"promise,omitempty"` +} + +func (x *OpeningFeeParams) Reset() { + *x = OpeningFeeParams{} + if protoimpl.UnsafeEnabled { + mi := &file_lspd_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *OpeningFeeParams) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpeningFeeParams) ProtoMessage() {} + +func (x *OpeningFeeParams) ProtoReflect() protoreflect.Message { + mi := &file_lspd_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OpeningFeeParams.ProtoReflect.Descriptor instead. +func (*OpeningFeeParams) Descriptor() ([]byte, []int) { + return file_lspd_proto_rawDescGZIP(), []int{2} +} + +func (x *OpeningFeeParams) GetMinMsat() uint64 { + if x != nil { + return x.MinMsat + } + return 0 +} + +func (x *OpeningFeeParams) GetProportional() uint32 { + if x != nil { + return x.Proportional + } + return 0 +} + +func (x *OpeningFeeParams) GetValidUntil() string { + if x != nil { + return x.ValidUntil + } + return "" +} + +func (x *OpeningFeeParams) GetMaxIdleTime() uint32 { + if x != nil { + return x.MaxIdleTime + } + return 0 +} + +func (x *OpeningFeeParams) GetMaxClientToSelfDelay() uint32 { + if x != nil { + return x.MaxClientToSelfDelay + } + return 0 +} + +func (x *OpeningFeeParams) GetPromise() string { + if x != nil { + return x.Promise + } + return "" +} + +type OpenChannelRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + /// The identity pubkey of the Lightning node + Pubkey string `protobuf:"bytes,1,opt,name=pubkey,proto3" json:"pubkey,omitempty"` +} + +func (x *OpenChannelRequest) Reset() { + *x = OpenChannelRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_lspd_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *OpenChannelRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenChannelRequest) ProtoMessage() {} + +func (x *OpenChannelRequest) ProtoReflect() protoreflect.Message { + mi := &file_lspd_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OpenChannelRequest.ProtoReflect.Descriptor instead. +func (*OpenChannelRequest) Descriptor() ([]byte, []int) { + return file_lspd_proto_rawDescGZIP(), []int{3} +} + +func (x *OpenChannelRequest) GetPubkey() string { + if x != nil { + return x.Pubkey + } + return "" +} + +type OpenChannelReply struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + /// The transaction hash + TxHash string `protobuf:"bytes,1,opt,name=tx_hash,proto3" json:"tx_hash,omitempty"` + /// The output index + OutputIndex uint32 `protobuf:"varint,2,opt,name=output_index,proto3" json:"output_index,omitempty"` +} + +func (x *OpenChannelReply) Reset() { + *x = OpenChannelReply{} + if protoimpl.UnsafeEnabled { + mi := &file_lspd_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *OpenChannelReply) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenChannelReply) ProtoMessage() {} + +func (x *OpenChannelReply) ProtoReflect() protoreflect.Message { + mi := &file_lspd_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OpenChannelReply.ProtoReflect.Descriptor instead. +func (*OpenChannelReply) Descriptor() ([]byte, []int) { + return file_lspd_proto_rawDescGZIP(), []int{4} +} + +func (x *OpenChannelReply) GetTxHash() string { + if x != nil { + return x.TxHash + } + return "" +} + +func (x *OpenChannelReply) GetOutputIndex() uint32 { + if x != nil { + return x.OutputIndex + } + return 0 +} + +type RegisterPaymentRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Blob []byte `protobuf:"bytes,3,opt,name=blob,proto3" json:"blob,omitempty"` +} + +func (x *RegisterPaymentRequest) Reset() { + *x = RegisterPaymentRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_lspd_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *RegisterPaymentRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegisterPaymentRequest) ProtoMessage() {} + +func (x *RegisterPaymentRequest) ProtoReflect() protoreflect.Message { + mi := &file_lspd_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegisterPaymentRequest.ProtoReflect.Descriptor instead. +func (*RegisterPaymentRequest) Descriptor() ([]byte, []int) { + return file_lspd_proto_rawDescGZIP(), []int{5} +} + +func (x *RegisterPaymentRequest) GetBlob() []byte { + if x != nil { + return x.Blob + } + return nil +} + +type RegisterPaymentReply struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *RegisterPaymentReply) Reset() { + *x = RegisterPaymentReply{} + if protoimpl.UnsafeEnabled { + mi := &file_lspd_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *RegisterPaymentReply) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegisterPaymentReply) ProtoMessage() {} + +func (x *RegisterPaymentReply) ProtoReflect() protoreflect.Message { + mi := &file_lspd_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegisterPaymentReply.ProtoReflect.Descriptor instead. +func (*RegisterPaymentReply) Descriptor() ([]byte, []int) { + return file_lspd_proto_rawDescGZIP(), []int{6} +} + +type PaymentInformation struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + PaymentHash []byte `protobuf:"bytes,1,opt,name=payment_hash,json=paymentHash,proto3" json:"payment_hash,omitempty"` + PaymentSecret []byte `protobuf:"bytes,2,opt,name=payment_secret,json=paymentSecret,proto3" json:"payment_secret,omitempty"` + Destination []byte `protobuf:"bytes,3,opt,name=destination,proto3" json:"destination,omitempty"` + IncomingAmountMsat int64 `protobuf:"varint,4,opt,name=incoming_amount_msat,json=incomingAmountMsat,proto3" json:"incoming_amount_msat,omitempty"` + OutgoingAmountMsat int64 `protobuf:"varint,5,opt,name=outgoing_amount_msat,json=outgoingAmountMsat,proto3" json:"outgoing_amount_msat,omitempty"` + Tag string `protobuf:"bytes,6,opt,name=tag,proto3" json:"tag,omitempty"` + OpeningFeeParams *OpeningFeeParams `protobuf:"bytes,7,opt,name=opening_fee_params,json=openingFeeParams,proto3" json:"opening_fee_params,omitempty"` +} + +func (x *PaymentInformation) Reset() { + *x = PaymentInformation{} + if protoimpl.UnsafeEnabled { + mi := &file_lspd_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *PaymentInformation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PaymentInformation) ProtoMessage() {} + +func (x *PaymentInformation) ProtoReflect() protoreflect.Message { + mi := &file_lspd_proto_msgTypes[7] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PaymentInformation.ProtoReflect.Descriptor instead. +func (*PaymentInformation) Descriptor() ([]byte, []int) { + return file_lspd_proto_rawDescGZIP(), []int{7} +} + +func (x *PaymentInformation) GetPaymentHash() []byte { + if x != nil { + return x.PaymentHash + } + return nil +} + +func (x *PaymentInformation) GetPaymentSecret() []byte { + if x != nil { + return x.PaymentSecret + } + return nil +} + +func (x *PaymentInformation) GetDestination() []byte { + if x != nil { + return x.Destination + } + return nil +} + +func (x *PaymentInformation) GetIncomingAmountMsat() int64 { + if x != nil { + return x.IncomingAmountMsat + } + return 0 +} + +func (x *PaymentInformation) GetOutgoingAmountMsat() int64 { + if x != nil { + return x.OutgoingAmountMsat + } + return 0 +} + +func (x *PaymentInformation) GetTag() string { + if x != nil { + return x.Tag + } + return "" +} + +func (x *PaymentInformation) GetOpeningFeeParams() *OpeningFeeParams { + if x != nil { + return x.OpeningFeeParams + } + return nil +} + +type Encrypted struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` +} + +func (x *Encrypted) Reset() { + *x = Encrypted{} + if protoimpl.UnsafeEnabled { + mi := &file_lspd_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Encrypted) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Encrypted) ProtoMessage() {} + +func (x *Encrypted) ProtoReflect() protoreflect.Message { + mi := &file_lspd_proto_msgTypes[8] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Encrypted.ProtoReflect.Descriptor instead. +func (*Encrypted) Descriptor() ([]byte, []int) { + return file_lspd_proto_rawDescGZIP(), []int{8} +} + +func (x *Encrypted) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +type Signed struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + Pubkey []byte `protobuf:"bytes,2,opt,name=pubkey,proto3" json:"pubkey,omitempty"` + Signature []byte `protobuf:"bytes,3,opt,name=signature,proto3" json:"signature,omitempty"` +} + +func (x *Signed) Reset() { + *x = Signed{} + if protoimpl.UnsafeEnabled { + mi := &file_lspd_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Signed) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Signed) ProtoMessage() {} + +func (x *Signed) ProtoReflect() protoreflect.Message { + mi := &file_lspd_proto_msgTypes[9] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Signed.ProtoReflect.Descriptor instead. +func (*Signed) Descriptor() ([]byte, []int) { + return file_lspd_proto_rawDescGZIP(), []int{9} +} + +func (x *Signed) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +func (x *Signed) GetPubkey() []byte { + if x != nil { + return x.Pubkey + } + return nil +} + +func (x *Signed) GetSignature() []byte { + if x != nil { + return x.Signature + } + return nil +} + +type CheckChannelsRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + EncryptPubkey []byte `protobuf:"bytes,1,opt,name=encrypt_pubkey,json=encryptPubkey,proto3" json:"encrypt_pubkey,omitempty"` + FakeChannels map[string]uint64 `protobuf:"bytes,2,rep,name=fake_channels,json=fakeChannels,proto3" json:"fake_channels,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"varint,2,opt,name=value,proto3"` + WaitingCloseChannels map[string]uint64 `protobuf:"bytes,3,rep,name=waiting_close_channels,json=waitingCloseChannels,proto3" json:"waiting_close_channels,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"varint,2,opt,name=value,proto3"` +} + +func (x *CheckChannelsRequest) Reset() { + *x = CheckChannelsRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_lspd_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CheckChannelsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CheckChannelsRequest) ProtoMessage() {} + +func (x *CheckChannelsRequest) ProtoReflect() protoreflect.Message { + mi := &file_lspd_proto_msgTypes[10] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CheckChannelsRequest.ProtoReflect.Descriptor instead. +func (*CheckChannelsRequest) Descriptor() ([]byte, []int) { + return file_lspd_proto_rawDescGZIP(), []int{10} +} + +func (x *CheckChannelsRequest) GetEncryptPubkey() []byte { + if x != nil { + return x.EncryptPubkey + } + return nil +} + +func (x *CheckChannelsRequest) GetFakeChannels() map[string]uint64 { + if x != nil { + return x.FakeChannels + } + return nil +} + +func (x *CheckChannelsRequest) GetWaitingCloseChannels() map[string]uint64 { + if x != nil { + return x.WaitingCloseChannels + } + return nil +} + +type CheckChannelsReply struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + NotFakeChannels map[string]uint64 `protobuf:"bytes,1,rep,name=not_fake_channels,json=notFakeChannels,proto3" json:"not_fake_channels,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"varint,2,opt,name=value,proto3"` + ClosedChannels map[string]uint64 `protobuf:"bytes,2,rep,name=closed_channels,json=closedChannels,proto3" json:"closed_channels,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"varint,2,opt,name=value,proto3"` +} + +func (x *CheckChannelsReply) Reset() { + *x = CheckChannelsReply{} + if protoimpl.UnsafeEnabled { + mi := &file_lspd_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CheckChannelsReply) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CheckChannelsReply) ProtoMessage() {} + +func (x *CheckChannelsReply) ProtoReflect() protoreflect.Message { + mi := &file_lspd_proto_msgTypes[11] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CheckChannelsReply.ProtoReflect.Descriptor instead. +func (*CheckChannelsReply) Descriptor() ([]byte, []int) { + return file_lspd_proto_rawDescGZIP(), []int{11} +} + +func (x *CheckChannelsReply) GetNotFakeChannels() map[string]uint64 { + if x != nil { + return x.NotFakeChannels + } + return nil +} + +func (x *CheckChannelsReply) GetClosedChannels() map[string]uint64 { + if x != nil { + return x.ClosedChannels + } + return nil +} + +var File_lspd_proto protoreflect.FileDescriptor + +var file_lspd_proto_rawDesc = []byte{ + 0x0a, 0x0a, 0x6c, 0x73, 0x70, 0x64, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x04, 0x6c, 0x73, + 0x70, 0x64, 0x22, 0x33, 0x0a, 0x19, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x49, 0x6e, 0x66, + 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x16, 0x0a, 0x06, 0x70, 0x75, 0x62, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x06, 0x70, 0x75, 0x62, 0x6b, 0x65, 0x79, 0x22, 0xd4, 0x04, 0x0a, 0x17, 0x43, 0x68, 0x61, 0x6e, + 0x6e, 0x65, 0x6c, 0x49, 0x6e, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, + 0x70, 0x6c, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x75, 0x62, 0x6b, 0x65, + 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x75, 0x62, 0x6b, 0x65, 0x79, 0x12, + 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, + 0x6f, 0x73, 0x74, 0x12, 0x2a, 0x0a, 0x10, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x63, + 0x61, 0x70, 0x61, 0x63, 0x69, 0x74, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x10, 0x63, + 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x63, 0x61, 0x70, 0x61, 0x63, 0x69, 0x74, 0x79, 0x12, + 0x20, 0x0a, 0x0b, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x05, 0x52, 0x0b, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x63, 0x6f, 0x6e, + 0x66, 0x12, 0x24, 0x0a, 0x0d, 0x62, 0x61, 0x73, 0x65, 0x5f, 0x66, 0x65, 0x65, 0x5f, 0x6d, 0x73, + 0x61, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x62, 0x61, 0x73, 0x65, 0x5f, 0x66, + 0x65, 0x65, 0x5f, 0x6d, 0x73, 0x61, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x66, 0x65, 0x65, 0x5f, 0x72, + 0x61, 0x74, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x01, 0x52, 0x08, 0x66, 0x65, 0x65, 0x5f, 0x72, + 0x61, 0x74, 0x65, 0x12, 0x28, 0x0a, 0x0f, 0x74, 0x69, 0x6d, 0x65, 0x5f, 0x6c, 0x6f, 0x63, 0x6b, + 0x5f, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0f, 0x74, 0x69, + 0x6d, 0x65, 0x5f, 0x6c, 0x6f, 0x63, 0x6b, 0x5f, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x12, 0x24, 0x0a, + 0x0d, 0x6d, 0x69, 0x6e, 0x5f, 0x68, 0x74, 0x6c, 0x63, 0x5f, 0x6d, 0x73, 0x61, 0x74, 0x18, 0x09, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x6d, 0x69, 0x6e, 0x5f, 0x68, 0x74, 0x6c, 0x63, 0x5f, 0x6d, + 0x73, 0x61, 0x74, 0x12, 0x36, 0x0a, 0x15, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x66, + 0x65, 0x65, 0x5f, 0x70, 0x65, 0x72, 0x6d, 0x79, 0x72, 0x69, 0x61, 0x64, 0x18, 0x0a, 0x20, 0x01, + 0x28, 0x03, 0x42, 0x02, 0x18, 0x01, 0x52, 0x13, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x46, + 0x65, 0x65, 0x50, 0x65, 0x72, 0x6d, 0x79, 0x72, 0x69, 0x61, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x6c, + 0x73, 0x70, 0x5f, 0x70, 0x75, 0x62, 0x6b, 0x65, 0x79, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0c, 0x52, + 0x09, 0x6c, 0x73, 0x70, 0x50, 0x75, 0x62, 0x6b, 0x65, 0x79, 0x12, 0x36, 0x0a, 0x15, 0x6d, 0x61, + 0x78, 0x5f, 0x69, 0x6e, 0x61, 0x63, 0x74, 0x69, 0x76, 0x65, 0x5f, 0x64, 0x75, 0x72, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x03, 0x42, 0x02, 0x18, 0x01, 0x52, 0x13, 0x6d, + 0x61, 0x78, 0x49, 0x6e, 0x61, 0x63, 0x74, 0x69, 0x76, 0x65, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x12, 0x3b, 0x0a, 0x18, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x6d, 0x69, + 0x6e, 0x69, 0x6d, 0x75, 0x6d, 0x5f, 0x66, 0x65, 0x65, 0x5f, 0x6d, 0x73, 0x61, 0x74, 0x18, 0x0d, + 0x20, 0x01, 0x28, 0x03, 0x42, 0x02, 0x18, 0x01, 0x52, 0x15, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, + 0x6c, 0x4d, 0x69, 0x6e, 0x69, 0x6d, 0x75, 0x6d, 0x46, 0x65, 0x65, 0x4d, 0x73, 0x61, 0x74, 0x12, + 0x4d, 0x0a, 0x17, 0x6f, 0x70, 0x65, 0x6e, 0x69, 0x6e, 0x67, 0x5f, 0x66, 0x65, 0x65, 0x5f, 0x70, + 0x61, 0x72, 0x61, 0x6d, 0x73, 0x5f, 0x6d, 0x65, 0x6e, 0x75, 0x18, 0x0e, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x16, 0x2e, 0x6c, 0x73, 0x70, 0x64, 0x2e, 0x4f, 0x70, 0x65, 0x6e, 0x69, 0x6e, 0x67, 0x46, + 0x65, 0x65, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x73, 0x52, 0x14, 0x6f, 0x70, 0x65, 0x6e, 0x69, 0x6e, + 0x67, 0x46, 0x65, 0x65, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x73, 0x4d, 0x65, 0x6e, 0x75, 0x22, 0xe8, + 0x01, 0x0a, 0x10, 0x4f, 0x70, 0x65, 0x6e, 0x69, 0x6e, 0x67, 0x46, 0x65, 0x65, 0x50, 0x61, 0x72, + 0x61, 0x6d, 0x73, 0x12, 0x19, 0x0a, 0x08, 0x6d, 0x69, 0x6e, 0x5f, 0x6d, 0x73, 0x61, 0x74, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x07, 0x6d, 0x69, 0x6e, 0x4d, 0x73, 0x61, 0x74, 0x12, 0x22, + 0x0a, 0x0c, 0x70, 0x72, 0x6f, 0x70, 0x6f, 0x72, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0c, 0x70, 0x72, 0x6f, 0x70, 0x6f, 0x72, 0x74, 0x69, 0x6f, 0x6e, + 0x61, 0x6c, 0x12, 0x1f, 0x0a, 0x0b, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x5f, 0x75, 0x6e, 0x74, 0x69, + 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x55, 0x6e, + 0x74, 0x69, 0x6c, 0x12, 0x22, 0x0a, 0x0d, 0x6d, 0x61, 0x78, 0x5f, 0x69, 0x64, 0x6c, 0x65, 0x5f, + 0x74, 0x69, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0b, 0x6d, 0x61, 0x78, 0x49, + 0x64, 0x6c, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x36, 0x0a, 0x18, 0x6d, 0x61, 0x78, 0x5f, 0x63, + 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x74, 0x6f, 0x5f, 0x73, 0x65, 0x6c, 0x66, 0x5f, 0x64, 0x65, + 0x6c, 0x61, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x14, 0x6d, 0x61, 0x78, 0x43, 0x6c, + 0x69, 0x65, 0x6e, 0x74, 0x54, 0x6f, 0x53, 0x65, 0x6c, 0x66, 0x44, 0x65, 0x6c, 0x61, 0x79, 0x12, + 0x18, 0x0a, 0x07, 0x70, 0x72, 0x6f, 0x6d, 0x69, 0x73, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x07, 0x70, 0x72, 0x6f, 0x6d, 0x69, 0x73, 0x65, 0x22, 0x2c, 0x0a, 0x12, 0x4f, 0x70, 0x65, + 0x6e, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x16, 0x0a, 0x06, 0x70, 0x75, 0x62, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x06, 0x70, 0x75, 0x62, 0x6b, 0x65, 0x79, 0x22, 0x50, 0x0a, 0x10, 0x4f, 0x70, 0x65, 0x6e, 0x43, + 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x74, + 0x78, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x74, 0x78, + 0x5f, 0x68, 0x61, 0x73, 0x68, 0x12, 0x22, 0x0a, 0x0c, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x5f, + 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0c, 0x6f, 0x75, 0x74, + 0x70, 0x75, 0x74, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x22, 0x2c, 0x0a, 0x16, 0x52, 0x65, 0x67, + 0x69, 0x73, 0x74, 0x65, 0x72, 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x62, 0x6c, 0x6f, 0x62, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x0c, 0x52, 0x04, 0x62, 0x6c, 0x6f, 0x62, 0x22, 0x16, 0x0a, 0x14, 0x52, 0x65, 0x67, 0x69, 0x73, + 0x74, 0x65, 0x72, 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x22, + 0xbc, 0x02, 0x0a, 0x12, 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x72, + 0x6d, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x21, 0x0a, 0x0c, 0x70, 0x61, 0x79, 0x6d, 0x65, 0x6e, + 0x74, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0b, 0x70, 0x61, + 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x48, 0x61, 0x73, 0x68, 0x12, 0x25, 0x0a, 0x0e, 0x70, 0x61, 0x79, + 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x0c, 0x52, 0x0d, 0x70, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, + 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x12, 0x30, 0x0a, 0x14, 0x69, 0x6e, 0x63, 0x6f, 0x6d, 0x69, 0x6e, 0x67, 0x5f, 0x61, + 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x6d, 0x73, 0x61, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x12, 0x69, 0x6e, 0x63, 0x6f, 0x6d, 0x69, 0x6e, 0x67, 0x41, 0x6d, 0x6f, 0x75, 0x6e, 0x74, + 0x4d, 0x73, 0x61, 0x74, 0x12, 0x30, 0x0a, 0x14, 0x6f, 0x75, 0x74, 0x67, 0x6f, 0x69, 0x6e, 0x67, + 0x5f, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x6d, 0x73, 0x61, 0x74, 0x18, 0x05, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x12, 0x6f, 0x75, 0x74, 0x67, 0x6f, 0x69, 0x6e, 0x67, 0x41, 0x6d, 0x6f, 0x75, + 0x6e, 0x74, 0x4d, 0x73, 0x61, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x74, 0x61, 0x67, 0x18, 0x06, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x03, 0x74, 0x61, 0x67, 0x12, 0x44, 0x0a, 0x12, 0x6f, 0x70, 0x65, 0x6e, + 0x69, 0x6e, 0x67, 0x5f, 0x66, 0x65, 0x65, 0x5f, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x73, 0x18, 0x07, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6c, 0x73, 0x70, 0x64, 0x2e, 0x4f, 0x70, 0x65, 0x6e, + 0x69, 0x6e, 0x67, 0x46, 0x65, 0x65, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x73, 0x52, 0x10, 0x6f, 0x70, + 0x65, 0x6e, 0x69, 0x6e, 0x67, 0x46, 0x65, 0x65, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x73, 0x22, 0x1f, + 0x0a, 0x09, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x64, + 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x22, + 0x52, 0x0a, 0x06, 0x53, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x61, 0x74, + 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x12, 0x16, 0x0a, + 0x06, 0x70, 0x75, 0x62, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x70, + 0x75, 0x62, 0x6b, 0x65, 0x79, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, + 0x72, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, + 0x75, 0x72, 0x65, 0x22, 0x86, 0x03, 0x0a, 0x14, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x43, 0x68, 0x61, + 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x25, 0x0a, 0x0e, + 0x65, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x5f, 0x70, 0x75, 0x62, 0x6b, 0x65, 0x79, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0d, 0x65, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x50, 0x75, 0x62, + 0x6b, 0x65, 0x79, 0x12, 0x51, 0x0a, 0x0d, 0x66, 0x61, 0x6b, 0x65, 0x5f, 0x63, 0x68, 0x61, 0x6e, + 0x6e, 0x65, 0x6c, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x6c, 0x73, 0x70, + 0x64, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x46, 0x61, 0x6b, 0x65, 0x43, 0x68, 0x61, 0x6e, 0x6e, + 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, 0x66, 0x61, 0x6b, 0x65, 0x43, 0x68, + 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x12, 0x6a, 0x0a, 0x16, 0x77, 0x61, 0x69, 0x74, 0x69, 0x6e, + 0x67, 0x5f, 0x63, 0x6c, 0x6f, 0x73, 0x65, 0x5f, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, + 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x34, 0x2e, 0x6c, 0x73, 0x70, 0x64, 0x2e, 0x43, 0x68, + 0x65, 0x63, 0x6b, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x2e, 0x57, 0x61, 0x69, 0x74, 0x69, 0x6e, 0x67, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x43, + 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x14, 0x77, 0x61, + 0x69, 0x74, 0x69, 0x6e, 0x67, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, + 0x6c, 0x73, 0x1a, 0x3f, 0x0a, 0x11, 0x46, 0x61, 0x6b, 0x65, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, + 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, + 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, + 0x02, 0x38, 0x01, 0x1a, 0x47, 0x0a, 0x19, 0x57, 0x61, 0x69, 0x74, 0x69, 0x6e, 0x67, 0x43, 0x6c, + 0x6f, 0x73, 0x65, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, + 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, + 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x04, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xcd, 0x02, 0x0a, + 0x12, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x52, 0x65, + 0x70, 0x6c, 0x79, 0x12, 0x59, 0x0a, 0x11, 0x6e, 0x6f, 0x74, 0x5f, 0x66, 0x61, 0x6b, 0x65, 0x5f, + 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2d, + 0x2e, 0x6c, 0x73, 0x70, 0x64, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x43, 0x68, 0x61, 0x6e, 0x6e, + 0x65, 0x6c, 0x73, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x2e, 0x4e, 0x6f, 0x74, 0x46, 0x61, 0x6b, 0x65, + 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0f, 0x6e, + 0x6f, 0x74, 0x46, 0x61, 0x6b, 0x65, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x12, 0x55, + 0x0a, 0x0f, 0x63, 0x6c, 0x6f, 0x73, 0x65, 0x64, 0x5f, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, + 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x6c, 0x73, 0x70, 0x64, 0x2e, 0x43, + 0x68, 0x65, 0x63, 0x6b, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x52, 0x65, 0x70, 0x6c, + 0x79, 0x2e, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x64, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, + 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0e, 0x63, 0x6c, 0x6f, 0x73, 0x65, 0x64, 0x43, 0x68, 0x61, + 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x1a, 0x42, 0x0a, 0x14, 0x4e, 0x6f, 0x74, 0x46, 0x61, 0x6b, 0x65, + 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, + 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, + 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, + 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x41, 0x0a, 0x13, 0x43, 0x6c, 0x6f, + 0x73, 0x65, 0x64, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, + 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, + 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x04, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x32, 0xae, 0x02, 0x0a, + 0x0d, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x4f, 0x70, 0x65, 0x6e, 0x65, 0x72, 0x12, 0x56, + 0x0a, 0x12, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x49, 0x6e, 0x66, 0x6f, 0x72, 0x6d, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1f, 0x2e, 0x6c, 0x73, 0x70, 0x64, 0x2e, 0x43, 0x68, 0x61, 0x6e, + 0x6e, 0x65, 0x6c, 0x49, 0x6e, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x6c, 0x73, 0x70, 0x64, 0x2e, 0x43, 0x68, 0x61, + 0x6e, 0x6e, 0x65, 0x6c, 0x49, 0x6e, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, + 0x65, 0x70, 0x6c, 0x79, 0x22, 0x00, 0x12, 0x41, 0x0a, 0x0b, 0x4f, 0x70, 0x65, 0x6e, 0x43, 0x68, + 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x12, 0x18, 0x2e, 0x6c, 0x73, 0x70, 0x64, 0x2e, 0x4f, 0x70, 0x65, + 0x6e, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x16, 0x2e, 0x6c, 0x73, 0x70, 0x64, 0x2e, 0x4f, 0x70, 0x65, 0x6e, 0x43, 0x68, 0x61, 0x6e, 0x6e, + 0x65, 0x6c, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x22, 0x00, 0x12, 0x4d, 0x0a, 0x0f, 0x52, 0x65, 0x67, + 0x69, 0x73, 0x74, 0x65, 0x72, 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x1c, 0x2e, 0x6c, + 0x73, 0x70, 0x64, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x50, 0x61, 0x79, 0x6d, + 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x6c, 0x73, 0x70, + 0x64, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, + 0x74, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x22, 0x00, 0x12, 0x33, 0x0a, 0x0d, 0x43, 0x68, 0x65, 0x63, + 0x6b, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x12, 0x0f, 0x2e, 0x6c, 0x73, 0x70, 0x64, + 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x1a, 0x0f, 0x2e, 0x6c, 0x73, 0x70, + 0x64, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x22, 0x00, 0x42, 0x3a, 0x0a, + 0x14, 0x69, 0x6f, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x6c, 0x73, 0x70, 0x64, 0x2e, 0x73, 0x65, + 0x72, 0x76, 0x69, 0x63, 0x65, 0x42, 0x09, 0x4c, 0x73, 0x70, 0x64, 0x50, 0x72, 0x6f, 0x74, 0x6f, + 0x50, 0x01, 0x5a, 0x15, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x62, + 0x72, 0x65, 0x65, 0x7a, 0x2f, 0x6c, 0x73, 0x70, 0x64, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x33, +} + +var ( + file_lspd_proto_rawDescOnce sync.Once + file_lspd_proto_rawDescData = file_lspd_proto_rawDesc +) + +func file_lspd_proto_rawDescGZIP() []byte { + file_lspd_proto_rawDescOnce.Do(func() { + file_lspd_proto_rawDescData = protoimpl.X.CompressGZIP(file_lspd_proto_rawDescData) + }) + return file_lspd_proto_rawDescData +} + +var file_lspd_proto_msgTypes = make([]protoimpl.MessageInfo, 16) +var file_lspd_proto_goTypes = []interface{}{ + (*ChannelInformationRequest)(nil), // 0: lspd.ChannelInformationRequest + (*ChannelInformationReply)(nil), // 1: lspd.ChannelInformationReply + (*OpeningFeeParams)(nil), // 2: lspd.OpeningFeeParams + (*OpenChannelRequest)(nil), // 3: lspd.OpenChannelRequest + (*OpenChannelReply)(nil), // 4: lspd.OpenChannelReply + (*RegisterPaymentRequest)(nil), // 5: lspd.RegisterPaymentRequest + (*RegisterPaymentReply)(nil), // 6: lspd.RegisterPaymentReply + (*PaymentInformation)(nil), // 7: lspd.PaymentInformation + (*Encrypted)(nil), // 8: lspd.Encrypted + (*Signed)(nil), // 9: lspd.Signed + (*CheckChannelsRequest)(nil), // 10: lspd.CheckChannelsRequest + (*CheckChannelsReply)(nil), // 11: lspd.CheckChannelsReply + nil, // 12: lspd.CheckChannelsRequest.FakeChannelsEntry + nil, // 13: lspd.CheckChannelsRequest.WaitingCloseChannelsEntry + nil, // 14: lspd.CheckChannelsReply.NotFakeChannelsEntry + nil, // 15: lspd.CheckChannelsReply.ClosedChannelsEntry +} +var file_lspd_proto_depIdxs = []int32{ + 2, // 0: lspd.ChannelInformationReply.opening_fee_params_menu:type_name -> lspd.OpeningFeeParams + 2, // 1: lspd.PaymentInformation.opening_fee_params:type_name -> lspd.OpeningFeeParams + 12, // 2: lspd.CheckChannelsRequest.fake_channels:type_name -> lspd.CheckChannelsRequest.FakeChannelsEntry + 13, // 3: lspd.CheckChannelsRequest.waiting_close_channels:type_name -> lspd.CheckChannelsRequest.WaitingCloseChannelsEntry + 14, // 4: lspd.CheckChannelsReply.not_fake_channels:type_name -> lspd.CheckChannelsReply.NotFakeChannelsEntry + 15, // 5: lspd.CheckChannelsReply.closed_channels:type_name -> lspd.CheckChannelsReply.ClosedChannelsEntry + 0, // 6: lspd.ChannelOpener.ChannelInformation:input_type -> lspd.ChannelInformationRequest + 3, // 7: lspd.ChannelOpener.OpenChannel:input_type -> lspd.OpenChannelRequest + 5, // 8: lspd.ChannelOpener.RegisterPayment:input_type -> lspd.RegisterPaymentRequest + 8, // 9: lspd.ChannelOpener.CheckChannels:input_type -> lspd.Encrypted + 1, // 10: lspd.ChannelOpener.ChannelInformation:output_type -> lspd.ChannelInformationReply + 4, // 11: lspd.ChannelOpener.OpenChannel:output_type -> lspd.OpenChannelReply + 6, // 12: lspd.ChannelOpener.RegisterPayment:output_type -> lspd.RegisterPaymentReply + 8, // 13: lspd.ChannelOpener.CheckChannels:output_type -> lspd.Encrypted + 10, // [10:14] is the sub-list for method output_type + 6, // [6:10] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name +} + +func init() { file_lspd_proto_init() } +func file_lspd_proto_init() { + if File_lspd_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_lspd_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ChannelInformationRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_lspd_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ChannelInformationReply); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_lspd_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*OpeningFeeParams); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_lspd_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*OpenChannelRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_lspd_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*OpenChannelReply); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_lspd_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*RegisterPaymentRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_lspd_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*RegisterPaymentReply); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_lspd_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*PaymentInformation); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_lspd_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Encrypted); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_lspd_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Signed); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_lspd_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CheckChannelsRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_lspd_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CheckChannelsReply); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_lspd_proto_rawDesc, + NumEnums: 0, + NumMessages: 16, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_lspd_proto_goTypes, + DependencyIndexes: file_lspd_proto_depIdxs, + MessageInfos: file_lspd_proto_msgTypes, + }.Build() + File_lspd_proto = out.File + file_lspd_proto_rawDesc = nil + file_lspd_proto_goTypes = nil + file_lspd_proto_depIdxs = nil +} diff --git a/rpc/lspd.proto b/rpc/lspd.proto new file mode 100644 index 0000000..4fe164f --- /dev/null +++ b/rpc/lspd.proto @@ -0,0 +1,118 @@ +syntax = "proto3"; + +option java_multiple_files = true; +option java_package = "io.grpc.lspd.service"; +option java_outer_classname = "LspdProto"; +option go_package = "github.com/breez/lspd"; + +package lspd; + +service ChannelOpener { + rpc ChannelInformation(ChannelInformationRequest) + returns (ChannelInformationReply) {} + rpc OpenChannel(OpenChannelRequest) returns (OpenChannelReply) {} + rpc RegisterPayment (RegisterPaymentRequest) returns (RegisterPaymentReply) {} + rpc CheckChannels(Encrypted) returns (Encrypted) {} +} + +message ChannelInformationRequest { + /// The identity pubkey of the Lightning node + string pubkey = 1 [ json_name = "pubkey" ]; +} + +message ChannelInformationReply { + /// The name of of lsp + string name = 1 [ json_name = "name" ]; + + /// The identity pubkey of the Lightning node + string pubkey = 2 [ json_name = "pubkey" ]; + /// The network location of the lightning node, e.g. `12.34.56.78:9012` or + /// `localhost:10011` + string host = 3 [ json_name = "host" ]; + + /// The channel capacity in satoshis + int64 channel_capacity = 4 [ json_name = "channel_capacity" ]; + /// The target number of blocks that the funding transaction should be + /// confirmed by. + int32 target_conf = 5 [ json_name = "target_conf" ]; + + /// The base fee charged regardless of the number of milli-satoshis sent. + int64 base_fee_msat = 6 [ json_name = "base_fee_msat" ]; + /// The effective fee rate in milli-satoshis. The precision of this value goes + /// up to 6 decimal places, so 1e-6. + double fee_rate = 7 [ json_name = "fee_rate" ]; + /// The required timelock delta for HTLCs forwarded over the channel. + uint32 time_lock_delta = 8 [ json_name = "time_lock_delta" ]; + /// The minimum value in millisatoshi we will require for incoming HTLCs on + /// the channel. + int64 min_htlc_msat = 9 [ json_name = "min_htlc_msat" ]; + + int64 channel_fee_permyriad = 10 [deprecated = true]; + + bytes lsp_pubkey = 11; + + // The channel can be closed if not used this duration in seconds. + int64 max_inactive_duration = 12 [deprecated = true]; + + int64 channel_minimum_fee_msat = 13 [deprecated = true]; + + repeated OpeningFeeParams opening_fee_params_menu = 14; +} + +message OpeningFeeParams { + uint64 min_msat = 1; + uint32 proportional = 2; + string valid_until = 3; + + // The channel can be closed if not used this duration in blocks. + uint32 max_idle_time = 4; + uint32 max_client_to_self_delay = 5; + string promise = 6; +} + +message OpenChannelRequest { + /// The identity pubkey of the Lightning node + string pubkey = 1 [ json_name = "pubkey" ]; +} + +message OpenChannelReply { + /// The transaction hash + string tx_hash = 1 [ json_name = "tx_hash" ]; + /// The output index + uint32 output_index = 2 [ json_name = "output_index"]; +} + +message RegisterPaymentRequest { + bytes blob = 3; +} +message RegisterPaymentReply {} +message PaymentInformation { + bytes payment_hash = 1; + bytes payment_secret = 2; + bytes destination = 3; + int64 incoming_amount_msat = 4; + int64 outgoing_amount_msat = 5; + string tag = 6; + OpeningFeeParams opening_fee_params = 7; +} + +message Encrypted { + bytes data = 1; +} + +message Signed { + bytes data = 1; + bytes pubkey = 2; + bytes signature = 3; +} + +message CheckChannelsRequest { + bytes encrypt_pubkey = 1; + map fake_channels = 2; + map waiting_close_channels = 3; +} + +message CheckChannelsReply { + map not_fake_channels = 1; + map closed_channels = 2; +} \ No newline at end of file diff --git a/rpc/lspd_grpc.pb.go b/rpc/lspd_grpc.pb.go new file mode 100644 index 0000000..d2160bc --- /dev/null +++ b/rpc/lspd_grpc.pb.go @@ -0,0 +1,213 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.2.0 +// - protoc v3.21.12 +// source: lspd.proto + +package lspd + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.32.0 or later. +const _ = grpc.SupportPackageIsVersion7 + +// ChannelOpenerClient is the client API for ChannelOpener service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type ChannelOpenerClient interface { + ChannelInformation(ctx context.Context, in *ChannelInformationRequest, opts ...grpc.CallOption) (*ChannelInformationReply, error) + OpenChannel(ctx context.Context, in *OpenChannelRequest, opts ...grpc.CallOption) (*OpenChannelReply, error) + RegisterPayment(ctx context.Context, in *RegisterPaymentRequest, opts ...grpc.CallOption) (*RegisterPaymentReply, error) + CheckChannels(ctx context.Context, in *Encrypted, opts ...grpc.CallOption) (*Encrypted, error) +} + +type channelOpenerClient struct { + cc grpc.ClientConnInterface +} + +func NewChannelOpenerClient(cc grpc.ClientConnInterface) ChannelOpenerClient { + return &channelOpenerClient{cc} +} + +func (c *channelOpenerClient) ChannelInformation(ctx context.Context, in *ChannelInformationRequest, opts ...grpc.CallOption) (*ChannelInformationReply, error) { + out := new(ChannelInformationReply) + err := c.cc.Invoke(ctx, "/lspd.ChannelOpener/ChannelInformation", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *channelOpenerClient) OpenChannel(ctx context.Context, in *OpenChannelRequest, opts ...grpc.CallOption) (*OpenChannelReply, error) { + out := new(OpenChannelReply) + err := c.cc.Invoke(ctx, "/lspd.ChannelOpener/OpenChannel", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *channelOpenerClient) RegisterPayment(ctx context.Context, in *RegisterPaymentRequest, opts ...grpc.CallOption) (*RegisterPaymentReply, error) { + out := new(RegisterPaymentReply) + err := c.cc.Invoke(ctx, "/lspd.ChannelOpener/RegisterPayment", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *channelOpenerClient) CheckChannels(ctx context.Context, in *Encrypted, opts ...grpc.CallOption) (*Encrypted, error) { + out := new(Encrypted) + err := c.cc.Invoke(ctx, "/lspd.ChannelOpener/CheckChannels", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// ChannelOpenerServer is the server API for ChannelOpener service. +// All implementations must embed UnimplementedChannelOpenerServer +// for forward compatibility +type ChannelOpenerServer interface { + ChannelInformation(context.Context, *ChannelInformationRequest) (*ChannelInformationReply, error) + OpenChannel(context.Context, *OpenChannelRequest) (*OpenChannelReply, error) + RegisterPayment(context.Context, *RegisterPaymentRequest) (*RegisterPaymentReply, error) + CheckChannels(context.Context, *Encrypted) (*Encrypted, error) + mustEmbedUnimplementedChannelOpenerServer() +} + +// UnimplementedChannelOpenerServer must be embedded to have forward compatible implementations. +type UnimplementedChannelOpenerServer struct { +} + +func (UnimplementedChannelOpenerServer) ChannelInformation(context.Context, *ChannelInformationRequest) (*ChannelInformationReply, error) { + return nil, status.Errorf(codes.Unimplemented, "method ChannelInformation not implemented") +} +func (UnimplementedChannelOpenerServer) OpenChannel(context.Context, *OpenChannelRequest) (*OpenChannelReply, error) { + return nil, status.Errorf(codes.Unimplemented, "method OpenChannel not implemented") +} +func (UnimplementedChannelOpenerServer) RegisterPayment(context.Context, *RegisterPaymentRequest) (*RegisterPaymentReply, error) { + return nil, status.Errorf(codes.Unimplemented, "method RegisterPayment not implemented") +} +func (UnimplementedChannelOpenerServer) CheckChannels(context.Context, *Encrypted) (*Encrypted, error) { + return nil, status.Errorf(codes.Unimplemented, "method CheckChannels not implemented") +} +func (UnimplementedChannelOpenerServer) mustEmbedUnimplementedChannelOpenerServer() {} + +// UnsafeChannelOpenerServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to ChannelOpenerServer will +// result in compilation errors. +type UnsafeChannelOpenerServer interface { + mustEmbedUnimplementedChannelOpenerServer() +} + +func RegisterChannelOpenerServer(s grpc.ServiceRegistrar, srv ChannelOpenerServer) { + s.RegisterService(&ChannelOpener_ServiceDesc, srv) +} + +func _ChannelOpener_ChannelInformation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ChannelInformationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChannelOpenerServer).ChannelInformation(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/lspd.ChannelOpener/ChannelInformation", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChannelOpenerServer).ChannelInformation(ctx, req.(*ChannelInformationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ChannelOpener_OpenChannel_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(OpenChannelRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChannelOpenerServer).OpenChannel(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/lspd.ChannelOpener/OpenChannel", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChannelOpenerServer).OpenChannel(ctx, req.(*OpenChannelRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ChannelOpener_RegisterPayment_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RegisterPaymentRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChannelOpenerServer).RegisterPayment(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/lspd.ChannelOpener/RegisterPayment", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChannelOpenerServer).RegisterPayment(ctx, req.(*RegisterPaymentRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ChannelOpener_CheckChannels_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Encrypted) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChannelOpenerServer).CheckChannels(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/lspd.ChannelOpener/CheckChannels", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChannelOpenerServer).CheckChannels(ctx, req.(*Encrypted)) + } + return interceptor(ctx, in, info, handler) +} + +// ChannelOpener_ServiceDesc is the grpc.ServiceDesc for ChannelOpener service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var ChannelOpener_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "lspd.ChannelOpener", + HandlerType: (*ChannelOpenerServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "ChannelInformation", + Handler: _ChannelOpener_ChannelInformation_Handler, + }, + { + MethodName: "OpenChannel", + Handler: _ChannelOpener_OpenChannel_Handler, + }, + { + MethodName: "RegisterPayment", + Handler: _ChannelOpener_RegisterPayment_Handler, + }, + { + MethodName: "CheckChannels", + Handler: _ChannelOpener_CheckChannels_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "lspd.proto", +} diff --git a/sample.env b/sample.env new file mode 100644 index 0000000..b595154 --- /dev/null +++ b/sample.env @@ -0,0 +1,59 @@ +# LISTEN_ADDRESS defines the host:port for the lspd grpc server. The best way is +# to use a local host:port and run lspd behind a reverse proxy like +# haproxy/nginx/caddy/traefik +LISTEN_ADDRESS= + +# You can also define a domain in CERTMAGIC_DOMAIN, and lspd will use certmagic +# to obtain a certificate from Let's Encrypt +#CERTMAGIC_DOMAIN= + +# DATABASE_URL is the postgresql db url in the form: +# postgres://username:password@host:port/dbname +# You can create the db and user with the following commands: +#CREATE ROLE ; +#ALTER ROLE WITH NOSUPERUSER INHERIT NOCREATEROLE NOCREATEDB LOGIN NOREPLICATION NOBYPASSRLS PASSWORD ''; +#CREATE DATABASE WITH TEMPLATE = template0 ENCODING = 'UTF8' LC_COLLATE = 'en_US.UTF-8' LC_CTYPE = 'en_US.UTF-8'; +#ALTER DATABASE OWNER TO ; +DATABASE_URL= + +# These variables are needed to send email using SES and the AWS_ACCESS_KEY_ID +# has to have the permission to send emails. +AWS_REGION= +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= + +OPENCHANNEL_NOTIFICATION_TO='["Name1 "]' +OPENCHANNEL_NOTIFICATION_CC='["Name2 ","Name3 "]' +OPENCHANNEL_NOTIFICATION_FROM="Name4 " + +CHANNELMISMATCH_NOTIFICATION_TO='["Name1 "]' +CHANNELMISMATCH_NOTIFICATION_CC='["Name2 ","Name3 "]' +CHANNELMISMATCH_NOTIFICATION_FROM="Name4 " + +# lspd uses the fee estimation from mempool.space for opening new channels. +# Change below setting for you own mempool instance. +MEMPOOL_API_BASE_URL=https://mempool.space/api/v1/ + +# Priority to use for opening channels when using the mempool api. +# Valid options are: fastest, halfhour, hour, economy, minimum +# Defaults to economy +MEMPOOL_PRIORITY=economy + +# lspd can be connected to multiple nodes at once. The NODES variable takes an +# array of nodes. Each node is either a cln or an lnd node and should have the +# corresponding "cln" or "lnd" key set. +# +# TOKEN is a secret shared between the LSP (the lspd instance) and breez-server +# and is put in the header of each request. It should be unique for each node. +# You can generate it using for instance the command: openssl rand -base64 24 +# +# LSPD_PRIVATE_KEY is a key generated and printed in the console when you run +# ./lspd genkey". +# When sending the lspd information to the client (the app), lspd adds the +# public key corresponding to this private key and the client encrypt all the +# messages sent to the LSP using this public key. +# The goal is to hide anything from breez-server which is the pass "opaque" data +# from the app to lspd. +# +# For other specific settings see the fields in `config.go` NodeConfig struct. +NODES='[ { "name": "", "nodePubkey": "", "lspdPrivateKey": "", "token": "", "host": "", "publicChannelAmount": "1000183", "channelAmount": "100000", "channelPrivate": false, "targetConf": "6", "minConfs": "6", "minHtlcMsat": "600", "baseFeeMsat": "1000", "feeRate": "0.000001", "timeLockDelta": "144", "channelFeePermyriad": "40", "channelMinimumFeeMsat": "2000000", "additionalChannelCapacity": "100000", "maxInactiveDuration": "3888000", "lnd": { "address": "", "cert": "", "macaroon": "" } }, { "name": "", "nodePubkey": "", "lspdPrivateKey": "", "token": "", "host": "", "publicChannelAmount": "1000183", "channelAmount": "100000", "channelPrivate": false, "targetConf": "6", "minConfs": "6", "minHtlcMsat": "600", "baseFeeMsat": "1000", "feeRate": "0.000001", "timeLockDelta": "144", "channelFeePermyriad": "40", "channelMinimumFeeMsat": "2000000", "additionalChannelCapacity": "100000", "maxInactiveDuration": "3888000", "cln": { "pluginAddress": "
", "socketPath": "" } } ]'