automation

This commit is contained in:
2023-08-18 17:02:53 +02:00
commit 33b7e1dd6d
119 changed files with 13464 additions and 0 deletions

23
.github/actions/build-lspd/action.yaml vendored Normal file
View File

@@ -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

View File

@@ -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'

View File

@@ -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

View File

@@ -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

16
.github/actions/setup-itest/action.yaml vendored Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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

144
.github/actions/test-lspd/action.yaml vendored Normal file
View File

@@ -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

126
.github/workflows/integration_tests.yaml vendored Normal file
View File

@@ -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 }}

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.vscode
go.sum
lspd
lspd_plugin

20
LICENSE Normal file
View File

@@ -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.

72
README.md Normal file
View File

@@ -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.

19
basetypes/outpoint.go Normal file
View File

@@ -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
}

View File

@@ -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)
}

3
basetypes/time.go Normal file
View File

@@ -0,0 +1,3 @@
package basetypes
var TIME_FORMAT string = "2006-01-02T15:04:05.999Z"

211
btceclegacy/ciphering.go Normal file
View File

@@ -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
}

21
chain/fee_estimator.go Normal file
View File

@@ -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)
}

437
channel_opener_server.go Normal file
View File

@@ -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
}

282
cln/cln_client.go Normal file
View File

@@ -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
}

309
cln/cln_interceptor.go Normal file
View File

@@ -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
}
}

View File

@@ -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
}

117
cln_plugin/cln_messages.go Normal file
View File

@@ -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"`
}

622
cln_plugin/cln_plugin.go Normal file
View File

@@ -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, &params)
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
}

21
cln_plugin/cmd/main.go Normal file
View File

@@ -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()
}

5
cln_plugin/genproto.sh Executable file
View File

@@ -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

View File

@@ -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
}

View File

@@ -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;
}

View File

@@ -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",
}

401
cln_plugin/server.go Normal file
View File

@@ -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",
}
}

98
config/config.go Normal file
View File

@@ -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"`
}

333
deploy/deploy.yml Normal file
View File

@@ -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<AWS::EC2::Image::Id>'
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=$(</dev/urandom tr -dc 'A-Za-z0-9' | head -c 20)
LIGHTNING_DB_PASSWORD=$(</dev/urandom tr -dc 'A-Za-z0-9' | head -c 20)
# Output the password to a file
echo "### PostgreSQL Credentials ###" >> "$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-$(</dev/urandom tr -dc 'A-Za-z0-9' | head -c 5)"
fi
# Install dependencies and required packages
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get upgrade -y
sudo apt-get install -y git autoconf automake build-essential libtool libgmp-dev libsqlite3-dev python3 python3-pip net-tools zlib1g-dev postgresql postgresql-client-common postgresql-client postgresql postgresql-contrib libpq5 libsodium-dev gettext cargo protobuf-compiler libgmp3-dev python-is-python3 libpq-dev jq
sudo pip3 install mako grpcio grpcio-tools
# Modify the pg_hba.conf file to set md5 password authentication for local connections
PG_VERSION=$(psql -V | awk '{print $3}' | awk -F"." '{print $1}')
sed -i 's/local all all peer/local all all md5/g' /etc/postgresql/$PG_VERSION/main/pg_hba.conf
# Create PostgreSQL users and databases
sudo -i -u postgres psql -c "CREATE ROLE lightning;"
sudo -i -u postgres psql -c "CREATE DATABASE lightning;"
sudo -i -u postgres psql -c "ALTER ROLE lightning WITH NOSUPERUSER INHERIT NOCREATEROLE NOCREATEDB LOGIN NOREPLICATION NOBYPASSRLS PASSWORD '$LIGHTNING_DB_PASSWORD';"
sudo -i -u postgres psql -c "ALTER DATABASE lightning OWNER TO lightning;"
sudo -i -u postgres psql -c "CREATE ROLE lspd;"
sudo -i -u postgres psql -c "ALTER ROLE lspd WITH NOSUPERUSER INHERIT NOCREATEROLE NOCREATEDB LOGIN NOREPLICATION NOBYPASSRLS PASSWORD '$LSPD_DB_PASSWORD';"
sudo -i -u postgres psql -c "CREATE DATABASE lspd WITH TEMPLATE = template0 ENCODING = 'UTF8' LC_COLLATE = 'en_US.UTF-8' LC_CTYPE = 'en_US.UTF-8';"
sudo -i -u postgres psql -c "ALTER DATABASE lspd OWNER TO lspd;"
# Restart PostgreSQL to apply changes
service postgresql restart
# Create directories under /opt
sudo mkdir -p /opt/lightning /opt/lspd
# Install go
wget https://go.dev/dl/go1.20.6.linux-amd64.tar.gz
sudo tar -C /usr/local -xzf go1.20.6.linux-amd64.tar.gz
echo "export PATH=$PATH:/usr/local/go/bin" | sudo tee -a /etc/bash.bashrc
source /etc/bash.bashrc
# Install rust
curl https://sh.rustup.rs -sSf | sh -s -- -y
# Install bitcoin
wget https://bitcoincore.org/bin/bitcoin-core-25.0/bitcoin-25.0-x86_64-linux-gnu.tar.gz -O /opt/bitcoin.tar.gz
tar -xzf /opt/bitcoin.tar.gz -C /opt/
cd /opt/bitcoin-*/bin
chmod 710 /etc/bitcoin
sudo install -m 0755 -t /usr/local/bin *
cat <<EOL | sudo tee /etc/systemd/system/bitcoind.service
[Unit]
Description=Bitcoin daemon
After=network.target
[Service]
WorkingDirectory=/var/lib/bitcoind
ExecStart=bitcoind -daemon -pid=/run/bitcoind/bitcoind.pid -conf=/etc/bitcoin/bitcoin.conf -datadir=/var/lib/bitcoind -startupnotify='systemd-notify --ready' -shutdownnotify='systemd-notify --stopping'
PermissionsStartOnly=true
ExecStartPre=/bin/chgrp bitcoin /var/lib/bitcoind
Type=notify
NotifyAccess=all
PIDFile=/run/bitcoind/bitcoind.pid
Restart=on-failure
TimeoutStartSec=infinity
TimeoutStopSec=600
User=bitcoin
Group=bitcoin
RuntimeDirectory=bitcoind
RuntimeDirectoryMode=0710
ConfigurationDirectory=bitcoin
StateDirectory=bitcoind
StateDirectoryMode=0710
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
NoNewPrivileges=true
PrivateDevices=true
MemoryDenyWriteExecute=true
[Install]
WantedBy=multi-user.target
EOL
# cat to a bitcoin.conf file
RPCPASSWORD=$(</dev/urandom tr -dc 'A-Za-z0-9' | head -c 20)
echo "### Bitcoin Configuration ###" >> "$CREDENTIALS"
echo "rpcuser: lnd" >> "$CREDENTIALS"
echo "rpcpassword: $RPCPASSWORD" >> "$CREDENTIALS"
sudo mkdir /etc/bitcoin/
sudo touch /etc/bitcoin/bitcoin.conf
cat <<EOL | sudo tee /etc/bitcoin/bitcoin.conf
txindex=1
daemon=1
rpcuser=lnd
rpcpassword=$RPCPASSWORD
minrelaytxfee=0.00000000
incrementalrelayfee=0.00000010
zmqpubrawblock=tcp://127.0.0.1:28332
zmqpubrawtx=tcp://127.0.0.1:28333
EOL
chmod 710 /etc/bitcoin
sudo mkdir /home/lightning/.bitcoin/
sudo mkdir /root/.bitcoin/
sudo ln -s /etc/bitcoin/bitcoin.conf /home/lightning/.bitcoin/bitcoin.conf
sudo ln -s /etc/bitcoin/bitcoin.conf /root/.bitcoin/bitcoin.conf
###################################
######## Install lightning ########
###################################
sudo mkdir /home/lightning/.lightning/
cat <<EOL | sudo tee /home/lightning/.lightning/config
bitcoin-rpcuser=lnd
bitcoin-rpcpassword=$RPCPASSWORD
bitcoin-rpcconnect=127.0.0.1
bitcoin-rpcport=8332
addr=:9735
bitcoin-retry-timeout=3600
alias="${LSPName}"
wallet=postgres://lightning:$LIGHTNING_DB_PASSWORD@localhost:5432/lightning
EOL
git clone https://github.com/ElementsProject/lightning.git /opt/lightning
cd /opt/lightning
git checkout v23.05
./configure --enable-developer
make
make install
cat <<EOL | sudo tee /etc/systemd/system/lightningd.service
[Unit]
Description=Lightning Network Daemon (lightningd)
After=network.target
[Service]
ExecStart=/usr/local/bin/lightningd --plugin=/home/lightning/.lightning/plugins/lspd_plugin --lsp-listen=127.0.0.1:12312 --max-concurrent-htlcs=30
MemoryDenyWriteExecute=true
NoNewPrivileges=true
PrivateDevices=true
PrivateTmp=true
ProtectSystem=full
Restart=on-failure
User=lightning
Group=lightning
EOL
# Install lspd
git clone https://github.com/breez/lspd.git /opt/lspd
cd /opt/lspd
source /etc/bash.bashrc
export PATH=$PATH:/usr/local/go/bin
sudo env "PATH=$PATH" go get
sudo env "PATH=$PATH" go get github.com/breez/lspd/cln_plugin
sudo env "PATH=$PATH" go build .
sudo env "PATH=$PATH" go build -o lspd_plugin ./cln_plugin/cmd
sudo cp lspd /usr/local/bin/
sudo mkdir /home/lightning/.lightning/plugins
sudo cp lspd_plugin /home/lightning/.lightning/plugins/
cat <<EOL | sudo tee /etc/systemd/system/lspd.service
[Unit]
Description=Lightning Service Daemon (lspd)
After=network.target
[Service]
User=lspd
EnvironmentFile=/home/lspd/.env
WorkingDirectory=/opt/lspd
ExecStart=/usr/local/bin/lspd
Restart=on-failure
RestartSec=5
EOL
sudo chown -R lightning:lightning /home/lightning/
sudo systemctl daemon-reload
sudo systemctl enable bitcoind.service
sudo systemctl enable lspd.service
sudo systemctl enable lightningd.service
sudo systemctl start bitcoind.service
sudo systemctl start lightningd.service
sleep 60
echo "### Lightning Credentials ###" >> "$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 <<EOL | sudo tee /home/lspd/.env
LISTEN_ADDRESS=0.0.0.0:8888
LSPD_PRIVATE_KEY="$LSPD_PRIVATE_KEY"
AWS_REGION="<REPLACE ME>"
AWS_ACCESS_KEY_ID="<REPLACE ME>"
AWS_SECRET_ACCESS_KEY="<REPLACE ME>"
DATABASE_URL="postgres://lspd:$LSPD_DB_PASSWORD@localhost/lspd"
OPENCHANNEL_NOTIFICATION_TO='["REPLACE ME <email@example.com>"]'
OPENCHANNEL_NOTIFICATION_CC='["REPLACE ME <test@example.com>"]'
OPENCHANNEL_NOTIFICATION_FROM="test@example.com"
CHANNELMISMATCH_NOTIFICATION_TO='["REPLACE ME <email@example.com>"]'
CHANNELMISMATCH_NOTIFICATION_CC='["REPLACE ME <email@example.com>"]'
CHANNELMISMATCH_NOTIFICATION_FROM="replaceme@example.com"
MEMPOOL_API_BASE_URL=https://mempool.space/api/v1/
MEMPOOL_PRIORITY=economy
NODES='[ { "name": "${LSPName}", "nodePubkey": "$PUBKEY", "lspdPrivateKey": "$LSPD_PRIVATE_KEY", "token": "$TOKEN", "host": "$EXTERNAL_IP:8888", "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": "127.0.0.1:12312", "socketPath": "/home/lightning/.lightning/bitcoin/lightning-rpc" } } ]'
EOL
sudo systemctl start lspd.service
echo "Installation complete"
sudo chmod 400 /home/lspd/credentials.txt
echo "Make sure to backup the credentials.txt file that can be found at /home/lspd/credentials.txt"
SecurityGroupIds:
- !GetAtt EC2SecurityGroup.GroupId
Tags:
- Key: Name
Value: lspd
# EC2 Elastic IP
EIP:
Type: 'AWS::EC2::EIP'
Properties:
Tags:
- Key: Name
Value: lspd
# EC2 Elastic IP Association
EIPAssociation:
Type: 'AWS::EC2::EIPAssociation'
Properties:
InstanceId: !Ref EC2Instance
EIP: !Ref EIP
# EC2 Security Group
EC2SecurityGroup:
Type: 'AWS::EC2::SecurityGroup'
Properties:
VpcId: !Ref VPCID
GroupDescription: Security Group for EC2 instance
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 22
ToPort: 22
CidrIp: 84.255.203.183/32
- IpProtocol: tcp
FromPort: 9735
ToPort: 9735
CidrIp: 0.0.0.0/0
- IpProtocol: tcp
FromPort: 8888
ToPort: 8888
CidrIp: 0.0.0.0/0

257
deploy/lspd-install.sh Normal file
View File

@@ -0,0 +1,257 @@
#!/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=$(</dev/urandom tr -dc 'A-Za-z0-9' | head -c 20)
LIGHTNING_DB_PASSWORD=$(</dev/urandom tr -dc 'A-Za-z0-9' | head -c 20)
# Output the password to a file
echo "### PostgreSQL Credentials ###" >> "$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-$(</dev/urandom tr -dc 'A-Za-z0-9' | head -c 5)"
fi
# Install dependencies and required packages
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get upgrade -y
sudo apt-get install -y git autoconf automake build-essential libtool libgmp-dev libsqlite3-dev python3 python3-pip net-tools zlib1g-dev postgresql postgresql-client-common postgresql-client postgresql postgresql-contrib libpq5 libsodium-dev gettext cargo protobuf-compiler libgmp3-dev python-is-python3 libpq-dev jq
sudo pip3 install mako grpcio grpcio-tools
# Modify the pg_hba.conf file to set md5 password authentication for local connections
PG_VERSION=$(psql -V | awk '{print $3}' | awk -F"." '{print $1}')
sed -i 's/local all all peer/local all all md5/g' /etc/postgresql/$PG_VERSION/main/pg_hba.conf
# Create PostgreSQL users and databases
sudo -i -u postgres psql -c "CREATE ROLE lightning;"
sudo -i -u postgres psql -c "CREATE DATABASE lightning;"
sudo -i -u postgres psql -c "ALTER ROLE lightning WITH NOSUPERUSER INHERIT NOCREATEROLE NOCREATEDB LOGIN NOREPLICATION NOBYPASSRLS PASSWORD '$LIGHTNING_DB_PASSWORD';"
sudo -i -u postgres psql -c "ALTER DATABASE lightning OWNER TO lightning;"
sudo -i -u postgres psql -c "CREATE ROLE lspd;"
sudo -i -u postgres psql -c "ALTER ROLE lspd WITH NOSUPERUSER INHERIT NOCREATEROLE NOCREATEDB LOGIN NOREPLICATION NOBYPASSRLS PASSWORD '$LSPD_DB_PASSWORD';"
sudo -i -u postgres psql -c "CREATE DATABASE lspd WITH TEMPLATE = template0 ENCODING = 'UTF8' LC_COLLATE = 'en_US.UTF-8' LC_CTYPE = 'en_US.UTF-8';"
sudo -i -u postgres psql -c "ALTER DATABASE lspd OWNER TO lspd;"
# Restart PostgreSQL to apply changes
service postgresql restart
# Create directories under /opt
sudo mkdir -p /opt/lightning /opt/lspd
# Install go
wget https://go.dev/dl/go1.20.6.linux-amd64.tar.gz
sudo tar -C /usr/local -xzf go1.20.6.linux-amd64.tar.gz
echo "export PATH=$PATH:/usr/local/go/bin" | sudo tee -a /etc/bash.bashrc
source /etc/bash.bashrc
# Install rust
curl https://sh.rustup.rs -sSf | sh -s -- -y
# Install bitcoin
wget https://bitcoincore.org/bin/bitcoin-core-25.0/bitcoin-25.0-x86_64-linux-gnu.tar.gz -O /opt/bitcoin.tar.gz
tar -xzf /opt/bitcoin.tar.gz -C /opt/
cd /opt/bitcoin-*/bin
chmod 710 /etc/bitcoin
sudo install -m 0755 -t /usr/local/bin *
cat <<EOL | sudo tee /etc/systemd/system/bitcoind.service
[Unit]
Description=Bitcoin daemon
After=network.target
[Service]
WorkingDirectory=/var/lib/bitcoind
ExecStart=bitcoind -daemon -pid=/run/bitcoind/bitcoind.pid -conf=/etc/bitcoin/bitcoin.conf -datadir=/var/lib/bitcoind -startupnotify='systemd-notify --ready' -shutdownnotify='systemd-notify --stopping'
PermissionsStartOnly=true
ExecStartPre=/bin/chgrp bitcoin /var/lib/bitcoind
Type=notify
NotifyAccess=all
PIDFile=/run/bitcoind/bitcoind.pid
Restart=on-failure
TimeoutStartSec=infinity
TimeoutStopSec=600
User=bitcoin
Group=bitcoin
RuntimeDirectory=bitcoind
RuntimeDirectoryMode=0710
ConfigurationDirectory=bitcoin
StateDirectory=bitcoind
StateDirectoryMode=0710
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
NoNewPrivileges=true
PrivateDevices=true
MemoryDenyWriteExecute=true
[Install]
WantedBy=multi-user.target
EOL
# cat to a bitcoin.conf file
RPCPASSWORD=$(</dev/urandom tr -dc 'A-Za-z0-9' | head -c 20)
echo "### Bitcoin Configuration ###" >> "$CREDENTIALS"
echo "rpcuser: lnd" >> "$CREDENTIALS"
echo "rpcpassword: $RPCPASSWORD" >> "$CREDENTIALS"
sudo mkdir /etc/bitcoin/
sudo touch /etc/bitcoin/bitcoin.conf
cat <<EOL | sudo tee /etc/bitcoin/bitcoin.conf
txindex=1
daemon=1
rpcuser=lnd
rpcpassword=$RPCPASSWORD
minrelaytxfee=0.00000000
incrementalrelayfee=0.00000010
zmqpubrawblock=tcp://127.0.0.1:28332
zmqpubrawtx=tcp://127.0.0.1:28333
EOL
chmod 710 /etc/bitcoin
sudo mkdir /home/lightning/.bitcoin/
sudo mkdir /root/.bitcoin/
sudo ln -s /etc/bitcoin/bitcoin.conf /home/lightning/.bitcoin/bitcoin.conf
sudo ln -s /etc/bitcoin/bitcoin.conf /root/.bitcoin/bitcoin.conf
###################################
######## Install lightning ########
###################################
sudo mkdir /home/lightning/.lightning/
cat <<EOL | sudo tee /home/lightning/.lightning/config
bitcoin-rpcuser=lnd
bitcoin-rpcpassword=$RPCPASSWORD
bitcoin-rpcconnect=127.0.0.1
bitcoin-rpcport=8332
addr=:9735
bitcoin-retry-timeout=3600
alias="${LSPName}"
wallet=postgres://lightning:$LIGHTNING_DB_PASSWORD@localhost:5432/lightning
EOL
git clone https://github.com/ElementsProject/lightning.git /opt/lightning
cd /opt/lightning
git checkout v23.05
./configure --enable-developer
make
make install
cat <<EOL | sudo tee /etc/systemd/system/lightningd.service
[Unit]
Description=Lightning Network Daemon (lightningd)
After=network.target
[Service]
ExecStart=/usr/local/bin/lightningd --plugin=/home/lightning/.lightning/plugins/lspd_plugin --lsp-listen=127.0.0.1:12312 --max-concurrent-htlcs=30
MemoryDenyWriteExecute=true
NoNewPrivileges=true
PrivateDevices=true
PrivateTmp=true
ProtectSystem=full
Restart=on-failure
User=lightning
Group=lightning
EOL
# Install lspd
git clone https://github.com/breez/lspd.git /opt/lspd
cd /opt/lspd
source /etc/bash.bashrc
export PATH=$PATH:/usr/local/go/bin
sudo env "PATH=$PATH" go get
sudo env "PATH=$PATH" go get github.com/breez/lspd/cln_plugin
sudo env "PATH=$PATH" go build .
sudo env "PATH=$PATH" go build -o lspd_plugin ./cln_plugin/cmd
sudo cp lspd /usr/local/bin/
sudo mkdir /home/lightning/.lightning/plugins
sudo cp lspd_plugin /home/lightning/.lightning/plugins/
cat <<EOL | sudo tee /etc/systemd/system/lspd.service
[Unit]
Description=Lightning Service Daemon (lspd)
After=network.target
[Service]
User=lspd
EnvironmentFile=/home/lspd/.env
WorkingDirectory=/opt/lspd
ExecStart=/usr/local/bin/lspd
Restart=on-failure
RestartSec=5
EOL
sudo chown -R lightning:lightning /home/lightning/
sudo systemctl daemon-reload
sudo systemctl enable bitcoind.service
sudo systemctl enable lspd.service
sudo systemctl enable lightningd.service
sudo systemctl start bitcoind.service
sudo systemctl start lightningd.service
sleep 60
echo "### Lightning Credentials ###" >> "$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 <<EOL | sudo tee /home/lspd/.env
LISTEN_ADDRESS=0.0.0.0:8888
LSPD_PRIVATE_KEY="$LSPD_PRIVATE_KEY"
AWS_REGION="<REPLACE ME>"
AWS_ACCESS_KEY_ID="<REPLACE ME>"
AWS_SECRET_ACCESS_KEY="<REPLACE ME>"
DATABASE_URL="postgres://lspd:$LSPD_DB_PASSWORD@localhost/lspd"
OPENCHANNEL_NOTIFICATION_TO='["REPLACE ME <email@example.com>"]'
OPENCHANNEL_NOTIFICATION_CC='["REPLACE ME <test@example.com>"]'
OPENCHANNEL_NOTIFICATION_FROM="test@example.com"
CHANNELMISMATCH_NOTIFICATION_TO='["REPLACE ME <email@example.com>"]'
CHANNELMISMATCH_NOTIFICATION_CC='["REPLACE ME <email@example.com>"]'
CHANNELMISMATCH_NOTIFICATION_FROM="replaceme@example.com"
MEMPOOL_API_BASE_URL=https://mempool.space/api/v1/
MEMPOOL_PRIORITY=economy
NODES='[ { "name": "${LSPName}", "nodePubkey": "$PUBKEY", "lspdPrivateKey": "$LSPD_PRIVATE_KEY", "token": "$TOKEN", "host": "$EXTERNAL_IP:8888", "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": "127.0.0.1:12312", "socketPath": "/home/lightning/.lightning/bitcoin/lightning-rpc" } } ]'
EOL
sudo systemctl start lspd.service
echo "Installation complete"
sudo chmod 400 /home/lspd/credentials.txt
echo "Make sure to backup the credentials.txt file that can be found at /home/lspd/credentials.txt"

74
docs/CLN.md Normal file
View File

@@ -0,0 +1,74 @@
## Installation instructions for core lightning and lspd
### Requirements
- CLN (complied with developer mode on) or LND
- lspd
- lspd plugin for cln
- postgresql
### Installation
#### CLN
Follow compilation steps for CLN [here](https://github.com/ElementsProject/lightning/blob/master/doc/getting-started/getting-started/installation.md) to enable developer mode.
#### 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 ./cln_plugin/cmd # compile lspd cln plugin
```
#### Postgresql
Lspd supports postgresql backend. To create database and new role to access it on your postgres server use:
##### Postgresql server
```
CREATE ROLE <username>;
ALTER ROLE <username> WITH NOSUPERUSER INHERIT NOCREATEROLE NOCREATEDB LOGIN NOREPLICATION NOBYPASSRLS PASSWORD '<password>';
CREATE DATABASE <dbname> WITH TEMPLATE = template0 ENCODING = 'UTF8' LC_COLLATE = 'en_US.UTF-8' LC_CTYPE = 'en_US.UTF-8';
ALTER DATABASE <dbname> OWNER TO <username>;
```
##### RDS on AWS
```
CREATE ROLE <username>;
ALTER ROLE <username> WITH INHERIT NOCREATEROLE NOCREATEDB LOGIN NOBYPASSRLS PASSWORD '<password>';
CREATE DATABASE <dbname> WITH TEMPLATE = template0 ENCODING = 'UTF8' LC_COLLATE = 'en_US.UTF-8' LC_CTYPE = 'en_US.UTF-8';
ALTER DATABASE <dbname> OWNER TO <username>;
```
### 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=<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)

53
docs/LND.md Normal file
View File

@@ -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 <username>;
ALTER ROLE <username> WITH NOSUPERUSER INHERIT NOCREATEROLE NOCREATEDB LOGIN NOREPLICATION NOBYPASSRLS PASSWORD '<password>';
CREATE DATABASE <dbname> WITH TEMPLATE = template0 ENCODING = 'UTF8' LC_COLLATE = 'en_US.UTF-8' LC_CTYPE = 'en_US.UTF-8';
ALTER DATABASE <dbname> OWNER TO <username>;
``````
### 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)

42
docs/aws.md Normal file
View File

@@ -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="<REPLACE ME>"
AWS_ACCESS_KEY_ID="<REPLACE ME>"
AWS_SECRET_ACCESS_KEY="<REPLACE ME>"
```
2) configure email
```
OPENCHANNEL_NOTIFICATION_TO='["REPLACE ME <email@example.com>"]'
OPENCHANNEL_NOTIFICATION_CC='["REPLACE ME <test@example.com>"]'
OPENCHANNEL_NOTIFICATION_FROM="test@example.com"
CHANNELMISMATCH_NOTIFICATION_TO='["REPLACE ME <email@example.com>"]'
CHANNELMISMATCH_NOTIFICATION_CC='["REPLACE ME <email@example.com>"]'
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.

57
docs/bash.md Normal file
View File

@@ -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="<REPLACE ME>"
AWS_ACCESS_KEY_ID="<REPLACE ME>"
AWS_SECRET_ACCESS_KEY="<REPLACE ME>"
```
2) configure email
```
OPENCHANNEL_NOTIFICATION_TO='["REPLACE ME <email@example.com>"]'
OPENCHANNEL_NOTIFICATION_CC='["REPLACE ME <test@example.com>"]'
OPENCHANNEL_NOTIFICATION_FROM="test@example.com"
CHANNELMISMATCH_NOTIFICATION_TO='["REPLACE ME <email@example.com>"]'
CHANNELMISMATCH_NOTIFICATION_CC='["REPLACE ME <email@example.com>"]'
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.

194
go.mod Normal file
View File

@@ -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

201
grpc_server.go Normal file
View File

@@ -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()
}
}

175
interceptor/email.go Normal file
View File

@@ -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 := `
<h2>NodeID: {{ .NodeID }}</h2>
{{ if .NotFakeChannels }}<h3>Channels not fake anynmore</h3><table>
<tr><th>Channel Point</th><th>Height Hint</th></tr>
{{ range $key, $value := .NotFakeChannels }}<tr><td>{{ $key }}</td><td>{{ $value }}</td></tr>{{ end }}
</table>{{ end }}
{{ if .ClosedChannels }}<h3>Closed Channels</h3><table>
<tr><th>Channel Point</th><th>Height Hint</th></tr>
{{ range $key, $value := .ClosedChannels }}<tr><td>{{ $key }}</td><td>{{ $value }}</td></tr>{{ end }}
</table>{{ 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 := `
<table>
<tr><td>Payment Hash:</td><td>{{ .PaymentHash }}</td></tr>
<tr><td>Incoming Amount (msat):</td><td>{{ .IncomingAmountMsat }}</td></tr>
<tr><td>Destination Node:</td><td>{{ .Destination }}</td></tr>
<tr><td>Channel capacity (sat):</td><td>{{ .Capacity }}</td></tr>
<tr><td>Channel point:</td><td>{{ .ChannelPoint }}</td></tr>
<tr><td>Tag:</td><td>{{ .Tag }}</td></tr>
</table>
`
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
}

View File

@@ -0,0 +1,7 @@
package interceptor
type HtlcInterceptor interface {
Start() error
Stop() error
WaitStarted()
}

427
interceptor/intercept.go Normal file
View File

@@ -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 := "<nil>"
var feeEstimation *float64
feeStr := "<nil>"
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
}

28
interceptor/store.go Normal file
View File

@@ -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)
}

69
itest/bob_offline_test.go Normal file
View File

@@ -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)
}

122
itest/breez_client.go Normal file
View File

@@ -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
}

335
itest/cln_breez_client.go Normal file
View File

@@ -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()
}

228
itest/cln_lspd_node.go Normal file
View File

@@ -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
}

58
itest/cltv_test.go Normal file
View File

@@ -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")
}

42
itest/config_test.go Normal file
View File

@@ -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)
}

141
itest/dynamic_fee_test.go Normal file
View File

@@ -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)
}

View File

@@ -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,
},
},
}
}

116
itest/lnd_breez_client.go Normal file
View File

@@ -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)
}
}
}

251
itest/lnd_lspd_node.go Normal file
View File

@@ -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
}

366
itest/lspd_node.go Normal file
View File

@@ -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
}

166
itest/lspd_test.go Normal file
View File

@@ -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,
},
}

85
itest/mempool_api.go Normal file
View File

@@ -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()
}

56
itest/no_balance_test.go Normal file
View File

@@ -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")
}

View File

@@ -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 &notificationDeliveryService{
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)
}

309
itest/notification_test.go Normal file
View File

@@ -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, &notifications.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, &notifications.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, &notifications.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)
}

268
itest/postgres.go Normal file
View File

@@ -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
}

75
itest/probing_test.go Normal file
View File

@@ -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")
}

View File

@@ -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)
}

55
itest/tag_test.go Normal file
View File

@@ -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)
}

54
itest/test_common.go Normal file
View File

@@ -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

46
itest/test_params.go Normal file
View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)
}

41
lightning/client.go Normal file
View File

@@ -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
}

528
lnd/client.go Normal file
View File

@@ -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")
}
}

View File

@@ -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
}

183
lnd/forwarding_history.go Normal file
View File

@@ -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
}

259
lnd/interceptor.go Normal file
View File

@@ -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
}

View File

@@ -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
}

185
main.go Normal file
View File

@@ -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")
}

90
mempool/mempool_client.go Normal file
View File

@@ -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
}

4
notifications/genproto.sh Executable file
View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 {
}

View File

@@ -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 &notificationsClient{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",
}

58
notifications/server.go Normal file
View File

@@ -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
}

10
notifications/store.go Normal file
View File

@@ -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)
}

17
postgresql/connect.go Normal file
View File

@@ -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
}

View File

@@ -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())
}

View File

@@ -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, &param)
if err != nil {
return nil, err
}
var params *interceptor.OpeningFeeParams
err := json.Unmarshal([]byte(param), &params)
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
}

View File

View File

View File

@@ -0,0 +1 @@
DROP TABLE public.payments;

View File

@@ -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)
);

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
ALTER TABLE public.payments DROP COLUMN funding_tx_id;
ALTER TABLE public.payments DROP COLUMN funding_tx_outnum;

View File

@@ -0,0 +1,2 @@
ALTER TABLE public.payments ADD funding_tx_id bytea NULL;
ALTER TABLE public.payments ADD funding_tx_outnum int NULL;

View File

@@ -0,0 +1 @@
DROP INDEX probe_payment_hash;

View File

@@ -0,0 +1 @@
CREATE INDEX probe_payment_hash ON public.payments (sha256('probing-01:' || payment_hash));

View File

@@ -0,0 +1 @@
DROP TABLE public.forwarding_history;

View File

@@ -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);

View File

@@ -0,0 +1 @@
DROP TABLE public.channels;

View File

@@ -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);

View File

@@ -0,0 +1 @@
ALTER TABLE public.channels DROP COLUMN last_update;

View File

@@ -0,0 +1 @@
ALTER TABLE public.channels ADD COLUMN last_update TIMESTAMP;

Some files were not shown because too many files have changed in this diff Show More