diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2d039de --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,140 @@ +name: CI + +# Cancel duplicate jobs +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +on: + workflow_call: + inputs: + cln-version: + required: true + type: string + pyln-version: + required: true + type: string + tagged-release: + required: true + type: boolean + +jobs: + build: + name: Test CLN=${{ inputs.cln-version }}, OS=${{ matrix.os }}, PY=${{ matrix.python-version }}, BCD=${{ matrix.bitcoind-version }}, EXP=${{ matrix.experimental }}, DEP=${{ matrix.deprecated }} + strategy: + fail-fast: false + matrix: + bitcoind-version: ["26.1"] + experimental: [1] + deprecated: [0] + python-version: ["3.8", "3.12"] + os: ["ubuntu-latest"] + + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Create cache paths + run: | + sudo mkdir /usr/local/libexec + sudo mkdir /usr/local/libexec/c-lightning + sudo mkdir /usr/local/libexec/c-lightning/plugins + sudo chown -R $USER /usr/local/libexec + + - name: Cache CLN + id: cache-cln + uses: actions/cache@v4 + with: + path: | + /usr/local/bin/lightning* + /usr/local/libexec/c-lightning + key: cache-cln-${{ inputs.cln-version }}-${{ runner.os }} + + - name: Cache bitcoind + id: cache-bitcoind + uses: actions/cache@v4 + with: + path: /usr/local/bin/bitcoin* + key: cache-bitcoind-${{ matrix.bitcoind-version }}-${{ runner.os }} + + - name: Download Bitcoin ${{ matrix.bitcoind-version }} & install binaries + if: ${{ steps.cache-bitcoind.outputs.cache-hit != 'true' }} + run: | + export BITCOIND_VERSION=${{ matrix.bitcoind-version }} + if [[ "${{ matrix.os }}" =~ "ubuntu" ]]; then + export TARGET_ARCH="x86_64-linux-gnu" + fi + if [[ "${{ matrix.os }}" =~ "macos" ]]; then + export TARGET_ARCH="x86_64-apple-darwin" + fi + wget https://bitcoincore.org/bin/bitcoin-core-${BITCOIND_VERSION}/bitcoin-${BITCOIND_VERSION}-${TARGET_ARCH}.tar.gz + tar -xzf bitcoin-${BITCOIND_VERSION}-${TARGET_ARCH}.tar.gz + sudo mv bitcoin-${BITCOIND_VERSION}/bin/* /usr/local/bin + rm -rf bitcoin-${BITCOIND_VERSION}-${TARGET_ARCH}.tar.gz bitcoin-${BITCOIND_VERSION} + + - name: Download Core Lightning ${{ inputs.cln-version }} & install binaries + if: ${{ contains(matrix.os, 'ubuntu') && steps.cache-cln.outputs.cache-hit != 'true' }} + run: | + url=$(curl -s https://api.github.com/repos/ElementsProject/lightning/releases/tags/${{ inputs.cln-version }} \ + | jq '.assets[] | select(.name | contains("22.04")) | .browser_download_url' \ + | tr -d '\"') + wget $url + sudo tar -xvf ${url##*/} -C /usr/local --strip-components=2 + echo "CLN_VERSION=$(lightningd --version)" >> "$GITHUB_OUTPUT" + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Checkout Core Lightning ${{ inputs.cln-version }} + if: ${{ contains(matrix.os, 'macos') && steps.cache-cln.outputs.cache-hit != 'true' }} + uses: actions/checkout@v4 + with: + repository: 'ElementsProject/lightning' + path: 'lightning' + ref: ${{ inputs.cln-version }} + submodules: 'recursive' + + - name: Install Python and System dependencies + run: | + if [[ "${{ matrix.os }}" =~ "macos" ]]; then + brew install autoconf automake libtool gnu-sed gettext libsodium sqlite + fi + python -m venv venv + source venv/bin/activate + python -m pip install -U pip poetry wheel + pip3 install "pyln-proto<=${{ inputs.pyln-version }}" "pyln-client<=${{ inputs.pyln-version }}" "pyln-testing<=${{ inputs.pyln-version }}" + pip3 install pytest-xdist pytest-test-groups pytest-timeout + pip3 install -r requirements.txt + + - name: Compile Core Lightning ${{ inputs.cln-version }} & install binaries + if: ${{ contains(matrix.os, 'macos') && steps.cache-cln.outputs.cache-hit != 'true' }} + run: | + export EXPERIMENTAL_FEATURES=${{ matrix.experimental }} + export COMPAT=${{ matrix.deprecated }} + export VALGRIND=0 + source venv/bin/activate + + cd lightning + + poetry lock + poetry install + ./configure --disable-valgrind + poetry run make + sudo make install + + - name: Run tests + run: | + export CLN_PATH=${{ github.workspace }}/lightning + export COMPAT=${{ matrix.deprecated }} + export EXPERIMENTAL_FEATURES=${{ matrix.experimental }} + export SLOW_MACHINE=1 + export TEST_DEBUG=1 + export TRAVIS=1 + export VALGRIND=0 + export PYTEST_TIMEOUT=600 + source venv/bin/activate + pytest -n=5 test_*.py diff --git a/.github/workflows/main_v24.02.yml b/.github/workflows/main_v24.02.yml new file mode 100644 index 0000000..5078a0e --- /dev/null +++ b/.github/workflows/main_v24.02.yml @@ -0,0 +1,23 @@ +name: main on CLN v24.02.2 + +on: + push: + branches: + - main + paths-ignore: + - 'Dockerfile' + - '*.md' + - 'LICENSE' + - '.gitignore' + - 'coffee.yml' + - '*.sh' + pull_request: + workflow_dispatch: + +jobs: + call-ci: + uses: ./.github/workflows/ci.yml + with: + cln-version: "v24.02.2" + pyln-version: "24.02" + tagged-release: false \ No newline at end of file diff --git a/README.md b/README.md index 4923e41..25008b8 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +[![main on CLN v24.02.2](https://github.com/gudnuf/bolt12-prism/actions/workflows/main_v24.02.yml/badge.svg?branch=main)](https://github.com/gudnuf/bolt12-prism/actions/workflows/main_v24.02.yml) + # BOLT12 Prism Plugin A CLN plugin for creating and interacting with prisms based on [BOLT12](https://bolt12.org). Prism payouts may be executed interactively (e.g., manually or via another CLN plugin), or bound to a BOLT12 offer. diff --git a/test_bolt12_prism.py b/test_bolt12_prism.py index 8036b1b..3b71060 100644 --- a/test_bolt12_prism.py +++ b/test_bolt12_prism.py @@ -1,33 +1,310 @@ import os +import time + +import pytest +from pyln.client import RpcError from pyln.testing.fixtures import * # noqa: F401,F403 -from pyln.client import Millisatoshi +from pyln.testing.utils import sync_blockheight, wait_for + +plugin_path = os.path.join(os.path.dirname(__file__), "./bolt12-prism.py") +plugin_opt = {"plugin": plugin_path} -plugin_path = os.path.join(os.path.dirname(__file__), './bolt12-prism.py') -plugin_opt = {'plugin': plugin_path} # spin up a network def test_basic_test(node_factory): - # Start two lightning nodes - l1, l2, l3, l4, l5 = node_factory.get_node(), node_factory.get_node(), node_factory.get_node(), node_factory.get_node(), node_factory.get_node() + # Start five lightning nodes + l1, l2, l3, l4, l5 = node_factory.get_nodes(5) # Connect the nodes in a prism layout. - l1.rpc.connect(l2.info['id'], 'localhost', l2.port) # Alice -> Bob - l2.rpc.connect(l3.info['id'], 'localhost', l3.port) # Bob -> Carol - l2.rpc.connect(l4.info['id'], 'localhost', l4.port) # Bob -> Dave - l2.rpc.connect(l5.info['id'], 'localhost', l5.port) # Bob -> Erin + l1.rpc.connect(l2.info["id"], "localhost", l2.port) # Alice -> Bob + l2.rpc.connect(l3.info["id"], "localhost", l3.port) # Bob -> Carol + l2.rpc.connect(l4.info["id"], "localhost", l4.port) # Bob -> Dave + l2.rpc.connect(l5.info["id"], "localhost", l5.port) # Bob -> Erin # Check the list of peers to confirm connection # Alice -> Bob - assert len(l1.rpc.listpeers()['peers']) == 1 - assert l1.rpc.listpeers()['peers'][0]['id'] == l2.info['id'] + assert len(l1.rpc.listpeers()["peers"]) == 1 + assert l1.rpc.listpeers()["peers"][0]["id"] == l2.info["id"] # Bob -> all others - assert len(l2.rpc.listpeers()['peers']) == 4 + assert len(l2.rpc.listpeers()["peers"]) == 4 # TODO we don't really know which index an id will be in; just search for existence? - #try: + # try: assert l2.rpc.plugin_start(plugin_path), "Failed to start plugin" - assert l2.rpc.plugin_list() + list_plugin = l2.rpc.plugin_list() + our_plugin = [ + plugin + for plugin in list_plugin["plugins"] + if "bolt12-prism" in plugin["name"] + ] + assert len(our_plugin) > 0 assert l2.rpc.prism_list() assert l2.rpc.prism_bindinglist() assert l2.rpc.plugin_stop(plugin_path) + + +def test_general_prism(node_factory, bitcoind): + l1, l2, l3, l4, l5 = node_factory.get_nodes( + 5, opts={"experimental-offers": None} + ) + nodes = [l1, l2, l3, l4, l5] + + # fund nodes, create channels, l2 is the prism + # + # -> l3 + # l1 -> l2 -> l4 + # -> l5 + # + l1.fundwallet(10_000_000) + l2.fundwallet(10_000_000) + l1.rpc.fundchannel(l2.info["id"] + "@localhost:" + str(l2.port), 1_000_000) + l2.rpc.fundchannel(l3.info["id"] + "@localhost:" + str(l3.port), 1_000_000) + bitcoind.generate_block(1) + sync_blockheight(bitcoind, nodes) + l2.rpc.fundchannel(l4.info["id"] + "@localhost:" + str(l4.port), 1_000_000) + bitcoind.generate_block(1) + sync_blockheight(bitcoind, nodes) + l2.rpc.fundchannel(l5.info["id"] + "@localhost:" + str(l5.port), 1_000_000) + bitcoind.generate_block(6) + sync_blockheight(bitcoind, nodes) + + l2.rpc.plugin_start(plugin_path) + + l3_offer = l3.rpc.offer("any", "Lead-Singer") + l4_offer = l4.rpc.offer("any", "Drummer") + l5_offer = l5.rpc.offer("any", "Guitarist") + + members_json = [ + { + "label": "Lead-Singer", + "destination": l3_offer["bolt12"], + "split": 1, + }, + { + "label": "Drummer", + "destination": l4_offer["bolt12"], + "split": 1, + }, + { + "label": "Guitarist", + "destination": l5_offer["bolt12"], + "split": 1, + }, + ] + prism1_id = "prism1" + + l2.rpc.call( + "prism-create", {"members": members_json, "prism_id": prism1_id} + ) + assert prism1_id in l2.rpc.call("prism-list")["prism_ids"] + # prism-show in README but not in code + # assert ( + # len(l2.rpc.call("prism-show", {"prism_id": prism1_id})["prism_members"]) + # == 3 + # ) + l2.rpc.call("prism-pay", {"prism_id": prism1_id, "amount_msat": 1_000_000}) + wait_for( + lambda: l3.rpc.listpeerchannels()["channels"][0]["to_us_msat"] > 300_000 + ) + wait_for( + lambda: l4.rpc.listpeerchannels()["channels"][0]["to_us_msat"] > 300_000 + ) + wait_for( + lambda: l5.rpc.listpeerchannels()["channels"][0]["to_us_msat"] > 300_000 + ) + + l2_offer = l2.rpc.offer("any", "Prism") + l2.rpc.call( + "prism-bindingadd", + {"bind_to": l2_offer["offer_id"], "prism_id": prism1_id}, + ) + binding = l2.rpc.call("prism-bindinglist")["bolt12_prism_bindings"][0] + assert binding["offer_id"] == l2_offer["offer_id"] + assert binding["prism_id"] == prism1_id + + invoice = l1.rpc.fetchinvoice(l2_offer["bolt12"], 1_000_000) + l1.rpc.pay(invoice["invoice"]) + wait_for( + lambda: l3.rpc.listpeerchannels()["channels"][0]["to_us_msat"] > 600_000 + ) + wait_for( + lambda: l4.rpc.listpeerchannels()["channels"][0]["to_us_msat"] > 600_000 + ) + wait_for( + lambda: l5.rpc.listpeerchannels()["channels"][0]["to_us_msat"] > 600_000 + ) + + l2.rpc.call("prism-bindingremove", {"offer_id": l2_offer["offer_id"]}) + assert len(l2.rpc.call("prism-bindinglist")["bolt12_prism_bindings"]) == 0 + + l2.rpc.call("prism-delete", {"prism_id": prism1_id}) + assert prism1_id not in l2.rpc.call("prism-list")["prism_ids"] + + +def test_splits(node_factory, bitcoind): + l1, l2, l3, l4 = node_factory.get_nodes( + 4, opts={"experimental-offers": None} + ) + nodes = [l1, l2, l3, l4] + + # fund nodes, create channels, l1 is the prism + # + # -> l2 + # l1 -> l3 + # -> l4 + # + l1.fundwallet(10_000_000) + l1.rpc.fundchannel(l2.info["id"] + "@localhost:" + str(l2.port), 1_000_000) + bitcoind.generate_block(1) + sync_blockheight(bitcoind, nodes) + l1.rpc.fundchannel(l3.info["id"] + "@localhost:" + str(l3.port), 1_000_000) + bitcoind.generate_block(1) + sync_blockheight(bitcoind, nodes) + l1.rpc.fundchannel(l4.info["id"] + "@localhost:" + str(l4.port), 1_000_000) + bitcoind.generate_block(6) + sync_blockheight(bitcoind, nodes) + + l1.rpc.plugin_start(plugin_path) + + l2_offer = l2.rpc.offer("any", "CEO") + l3_offer = l3.rpc.offer("any", "CTO") + l4_offer = l4.rpc.offer("any", "Janitor") + + prism1_id = "prism1" + + members_json_float = [ + { + "label": "CEO", + "destination": l2_offer["bolt12"], + "split": 0.7, + }, + { + "label": "CTO", + "destination": l3_offer["bolt12"], + "split": 0.3, + }, + ] + with pytest.raises(RpcError, match="must be an integer"): + l1.rpc.call( + "prism-create", + {"members": members_json_float, "prism_id": prism1_id}, + ) + + members_json = [ + { + "label": "CEO", + "destination": l2_offer["bolt12"], + "split": 5, + }, + { + "label": "CTO", + "destination": l3_offer["bolt12"], + "split": 3, + }, + { + "label": "Janitor", + "destination": l4_offer["bolt12"], + "split": 1, + }, + ] + + l1.rpc.call( + "prism-create", {"members": members_json, "prism_id": prism1_id} + ) + l1.rpc.call("prism-pay", {"prism_id": prism1_id, "amount_msat": 1_000_000}) + wait_for( + lambda: l2.rpc.listpeerchannels()["channels"][0]["to_us_msat"] > 555_000 + ) + wait_for( + lambda: l3.rpc.listpeerchannels()["channels"][0]["to_us_msat"] > 333_000 + ) + wait_for( + lambda: l4.rpc.listpeerchannels()["channels"][0]["to_us_msat"] > 111_000 + ) + + +def test_payment_threshold(node_factory, bitcoind): + l1, l2, l3, l4, l5 = node_factory.get_nodes( + 5, opts={"experimental-offers": None} + ) + nodes = [l1, l2, l3, l4, l5] + + # fund nodes, create channels, l2 is the prism + # + # -> l3 + # l1 -> l2 -> l4 + # -> l5 + # + l1.fundwallet(10_000_000) + l2.fundwallet(10_000_000) + l1.rpc.fundchannel(l2.info["id"] + "@localhost:" + str(l2.port), 1_000_000) + l2.rpc.fundchannel(l3.info["id"] + "@localhost:" + str(l3.port), 1_000_000) + bitcoind.generate_block(1) + sync_blockheight(bitcoind, nodes) + l2.rpc.fundchannel(l4.info["id"] + "@localhost:" + str(l4.port), 1_000_000) + bitcoind.generate_block(1) + sync_blockheight(bitcoind, nodes) + l2.rpc.fundchannel(l5.info["id"] + "@localhost:" + str(l5.port), 1_000_000) + bitcoind.generate_block(6) + sync_blockheight(bitcoind, nodes) + + l2.rpc.plugin_start(plugin_path) + + l3_offer = l3.rpc.offer("any", "Lead-Singer") + l4_offer = l4.rpc.offer("any", "Drummer") + l5_offer = l5.rpc.offer("any", "Guitarist") + + members_json = [ + { + "label": "Lead-Singer", + "destination": l3_offer["bolt12"], + "split": 1, + "payout_threshold": 500_000, + }, + { + "label": "Drummer", + "destination": l4_offer["bolt12"], + "split": 1, + }, + { + "label": "Guitarist", + "destination": l5_offer["bolt12"], + "split": 1, + }, + ] + prism1_id = "prism1" + + l2.rpc.call( + "prism-create", {"members": members_json, "prism_id": prism1_id} + ) + + l2_offer = l2.rpc.offer("any", "Prism") + l2.rpc.call( + "prism-bindingadd", + {"bind_to": l2_offer["offer_id"], "prism_id": prism1_id}, + ) + + invoice = l1.rpc.fetchinvoice(l2_offer["bolt12"], 1_000_000) + l1.rpc.pay(invoice["invoice"]) + wait_for( + lambda: l4.rpc.listpeerchannels()["channels"][0]["to_us_msat"] > 300_000 + ) + wait_for( + lambda: l5.rpc.listpeerchannels()["channels"][0]["to_us_msat"] > 300_000 + ) + # hold, CI can be slow + time.sleep(5) + assert l3.rpc.listpeerchannels()["channels"][0]["to_us_msat"] == 0 + + invoice = l1.rpc.fetchinvoice(l2_offer["bolt12"], 1_000_000) + l1.rpc.pay(invoice["invoice"]) + wait_for( + lambda: l3.rpc.listpeerchannels()["channels"][0]["to_us_msat"] > 600_000 + ) + wait_for( + lambda: l4.rpc.listpeerchannels()["channels"][0]["to_us_msat"] > 600_000 + ) + wait_for( + lambda: l5.rpc.listpeerchannels()["channels"][0]["to_us_msat"] > 600_000 + )