├── .dockerignore ├── .flake8 ├── .github └── workflows │ ├── build.yml │ └── testing.yml ├── .gitignore ├── CHANGELOG.md ├── HACKING.md ├── LICENSE ├── Makefile ├── README.md ├── changelog.json ├── docker ├── Dockerfile.clightning ├── Dockerfile.ldk ├── docker-compose.yml ├── entrypoint.sh └── ldk-entrypoint.sh ├── lnprototest ├── __init__.py ├── backend │ ├── __init__.py │ ├── backend.py │ └── bitcoind.py ├── bitfield.py ├── clightning │ ├── __init__.py │ ├── clightning.py │ └── requirements.txt ├── commit_tx.py ├── dummyrunner.py ├── errors.py ├── event.py ├── funding.py ├── keyset.py ├── namespace.py ├── proposals.py ├── runner.py ├── signature.py ├── stash │ ├── __init__.py │ └── stash.py ├── structure.py └── utils │ ├── __init__.py │ ├── bitcoin_utils.py │ ├── ln_spec_utils.py │ └── utils.py ├── poetry.lock ├── pyproject.toml ├── tests ├── conftest.py ├── pytest.ini ├── test_bolt1-01-init.py ├── test_bolt1-02-unknown-messages.py ├── test_bolt2-01-close_channel.py ├── test_bolt2-01-open_channel.py ├── test_bolt2-02-reestablish.py ├── test_bolt2-10-add-htlc.py ├── test_bolt2-20-open_channel_accepter.py ├── test_bolt2-30-channel_type-open-accept-tlvs.py ├── test_bolt7-01-channel_announcement-success.py ├── test_bolt7-02-channel_announcement-failure.py ├── test_bolt7-10-gossip-filter.py └── test_bolt7-20-query_channel_range.py └── tools └── check_quotes.py /.dockerignore: -------------------------------------------------------------------------------- 1 | **/__py* 2 | **/mypy_* 3 | **/*.venv* 4 | **/*.egg-info -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | extend-ignore = E203 4 | per-file-ignores = __init__.py:F401 -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and check code style 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: [3.9, 3.11, 3.12, 3.13] 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install poetry 27 | poetry config virtualenvs.create false 28 | poetry install 29 | - name: Code style check 30 | run: | 31 | black . --check --diff 32 | - name: Test with pytest 33 | run: | 34 | pytest tests 35 | -------------------------------------------------------------------------------- /.github/workflows/testing.yml: -------------------------------------------------------------------------------- 1 | name: Integration testing 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | schedule: 9 | - cron: '30 1 1,15 * *' 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | LN_IMPL: [clightning, ldk] 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | - name: Integration testing 22 | run: | 23 | docker build -f docker/Dockerfile.${{matrix.LN_IMPL}} -t lnprototest-${{matrix.LN_IMPL}} . 24 | docker run -e LN_IMPL=${{matrix.LN_IMPL}} lnprototest-${{matrix.LN_IMPL}} 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *~ 3 | TAGS 4 | tags 5 | **/github-merge.py 6 | .venv 7 | **.egg-info 8 | .idea 9 | dist 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v0.0.5 2 | 3 | ## Fixed 4 | - Correct reorg issue in the ln helper function ([commit](https://github.com/rustyrussell/lnprototest/commit/ad3129d4dad14e98a0b3f1210d349d2b6503ce53)). @vincenzopalazzo 21-09-2023 5 | - Correct error check for expected errors ([commit](https://github.com/rustyrussell/lnprototest/commit/3dafe1d0b010aa0fb98b545a3a0fef6985153087)). @vincenzopalazzo 15-07-2023 6 | - runner_features added in runner.py and applied in ln_spec_utils.py ([commit](https://github.com/rustyrussell/lnprototest/commit/0dc5dddbb209bd8edf4d2a93973b72882724b865)). @Psycho-Pirate 24-06-2023 7 | - Modified Reestablish test to use new code and used stash ([commit](https://github.com/rustyrussell/lnprototest/commit/6cd0791f961d7c8d596d45c9725a463e5f52eed4)). @Psycho-Pirate 19-06-2023 8 | - resolve the number callback in Block event ([commit](https://github.com/rustyrussell/lnprototest/commit/7ff8d9f33253031ff83bc9aed5ff1b63bd42bf95)). @vincenzopalazzo 06-06-2023 9 | 10 | ## Added 11 | - Allowing the option to skip the creation of the wallet in bitcoind ([commit](https://github.com/rustyrussell/lnprototest/commit/45711defc89161b8d3efcae747000ae4fb2fd36d)). @vincenzopalazzo 27-07-2023 12 | - Open channel helper ignores announcement signatures ([commit](https://github.com/rustyrussell/lnprototest/commit/ba32816e3291055e7f1eeff01f872f8a3359f66b)). @vincenzopalazzo 16-06-2023 13 | - Support the Drop of the --developer flag at runtime (cln: specify --developer if supported. rustyrussell/lnprototest#106). @rustyrussell 14 | 15 | 16 | # v0.0.4 17 | 18 | ## Fixed 19 | - Grab the feature from the init message if it is not specified ([commit](https://github.com/rustyrussell/lnprototest/commit/e2005d731bacf4fffaf4fe92cc96b1d241bde7f8)). @vincenzopalazzo 03-06-2023 20 | - pass the timeout when we are waiting a node to start ([commit](https://github.com/rustyrussell/lnprototest/commit/9b4a0c1521eddee3f1c90aae6bab1aac120c4cba)). @vincenzopalazzo 01-06-2023 21 | - import inside the library some useful utils ([commit](https://github.com/rustyrussell/lnprototest/commit/554659fbdce8376f9f200e98a05f44b3b5c0582c)). @vincenzopalazzo 01-06-2023 22 | 23 | ## Added 24 | - Enable stashing of expected messages ([commit](https://github.com/rustyrussell/lnprototest/commit/50d72b1f8b08c3d973c8252f3be3c28812247404)). @vincenzopalazzo 01-06-2023 25 | -------------------------------------------------------------------------------- /HACKING.md: -------------------------------------------------------------------------------- 1 | # Adding New Tests, Testing New Nodes 2 | 3 | The most common thing to do is to add a new test for a new feature. 4 | 5 | ## Adding A New Test 6 | 7 | To add a new test, simply add a file starting with `test_` to the 8 | tests/ directory. Every function in this file starting with `test_` 9 | will be run (the rest, presumably, are helpers you need). 10 | 11 | For every test, there is a runner which wraps a particular node 12 | implementation: using the default "DummyRunner" helps debug the tests 13 | themselves. 14 | 15 | A test consists of one or more Events (e.g. send a message, receive a 16 | message), in a DAG. The test runner repeats the test until every 17 | Event has been covered. The most important event is probably 18 | TryAll(), which gives multiple alternative paths of Events, each of 19 | which should be tried (it will try the "most Events" path first, to 20 | try to get maximum coverage early in testing). 21 | 22 | Tests which don't have an ExpectError event have a check at the end to 23 | make sure no errors occurred. 24 | 25 | ## Using ExpectMsg Events 26 | 27 | `ExpectMsg` matches a (perhaps only partially defined) message, then 28 | calls its `if_match` function which can do more fine-grained matching. 29 | For example, it could check that a specific field is not specified, or 30 | a specific bit is set, etc. There's also `ignore` which is a list 31 | of Message to ignore: it defaults to common gossip queries. 32 | 33 | `ExpectMsg` also stores the received fields in the runner's `stash`: 34 | the convenient `rcvd` function can be used to access them for use in 35 | `Msg` fields. 36 | 37 | 38 | ## Creating New Event Types 39 | 40 | For various special effects, you might want to create a new Event 41 | subclass. 42 | 43 | Events are constructed once, but then their `action` method is called 44 | in multiple orders for multiple traverses: they can store state across 45 | runs in the `runner` using its `add_stash()` and `get_stash()` 46 | methods, as used by `ExpectMsg` and `Msg`. The entire stash 47 | is emptied upon restart. 48 | 49 | 50 | ## Test Checklist 51 | 52 | 1. Did you quote the part of the BOLT you are testing? This is vital 53 | to make your tests readable, and to ensure they change with the 54 | spec. `make check-quotes` will all the quotes (starting with `# 55 | BOLT #N:`) are correct based on the `../lightning-rfc` directory, 56 | or run `tools/check_quotes.py testfile`. If you are creating tests 57 | for a specific (e.g. non-master) git revision, you can use `# 58 | BOLT-commitid #N:` and use `--include-commit=commitid` option for 59 | every commit id it should check. 60 | 61 | 2. Does your test check failures as well as successes? 62 | 63 | 3. Did you test something which wasn't clear in the spec? Consider 64 | opening a PR or issue to add an explicit requirement. 65 | 66 | 4. Does it pass `make check-source` a.k.a. flake8 and mypy? 67 | 68 | ## Adding a New Runner 69 | 70 | You can write a new runner for an implementation by inheriting from 71 | the Runner class. This runner could live in this repository or in 72 | your implementation's repository: you can set it with 73 | `--runner=modname.classname`. 74 | 75 | This is harder than writing a new test, but ultimately far more 76 | useful, as it expands the coverage of every new test. 77 | 78 | To add a new runner, you'll need to create a new subclass of Runner, that 79 | fills in the Runner API. You can find a good skeleton for a new runner in 80 | `lnprototest/dummyrunner.py` 81 | 82 | A completed core-lightning example runner can be found in `lnprototest/clightning/clightning.py` 83 | 84 | Here's a short outline of the current expected methods for a Runner. 85 | 86 | - `get_keyset`: returns the node's KeySet (`revocation_base_secret`, `payment_base_secret`, `htlc_base_secret`, and `shachain_seed`) 87 | - `get_node_privkey`: Private key of the node. Used to generate the node id and establish a communication channel with the node under test. 88 | - `get_node_bitcoinkey`: Private key of the node under test's funding pubkey 89 | - `has_option`: checks for features (e.g. `option_anchor_outputs`) in which cast it returns `None`, or "even" or "odd" (required or supported). Also checks for non-feature-bit features, such as `supports_open_accept_channel_types` which returns `None` or "true". 90 | - `add_startup_flag`: Add flag to runner's startup. 91 | - `start`: Starts up / initializes the node under test. 92 | - `stop`: Stops the node under test and closes the connection. 93 | - `restart`: Restarts the node under tests, closes the existing connection, cleans up the existing test files, and restarts bitcoind. Note that it's useful to print a `RESTART` log when verbose logging is activated, e.g. 94 | 95 | if self.config.getoption('verbose'): 96 | print("[RESTART]") 97 | 98 | - `connect`: Create a connection to the node under test using the provided `connprivkey`. 99 | - `getblockheight`: Return the blockcount from bitcoind 100 | - `trim_blocks`: Invalidate bitcoind blocks until `newheight` 101 | - `add_blocks`: Send provided `txs` (if any). Generate `n` new blocks. 102 | - `disconnect`: Implemented in the parent Runner, not necessary to implement in child unless necessary. 103 | - `recv`: Send `outbuf` over `conn` to node under test 104 | - `fundchannel`: Initiate a fundchannel attempt to the connection's pubkey (the test harness) for the given `amount` and `feerate`. MUST NOT block (should execute this fundchannel request on a secondary thread) 105 | - `init_rbf`: For v2 channel opens, initiates an RBF attempt. Same as `fundchannel`, must not block. 106 | - `invoice`: Generate an invoice from the node under test for the given amount and preimage 107 | - `accept_add_fund`: Configure the node under test to contribute to any incoming v2 open channel offers. 108 | - `addhtlc`: Add the provided htlc to the the node. core lightning does this via the `sendpay` command 109 | - `get_output_message`: Read a message from the node's connection 110 | - `expect_tx`: Wait for the provided txid to appear in the mempool 111 | - `check_error`: Gets message from connection and returns it as hex. Also calls parent Runner method (which marks this as an `expected_error`) 112 | - `check_final_error`: Called by Runner.disconnect(). Closes the connection by forcing a disconnect on the peer. Processes all remaining messages from peer. Raises EventError if error message is returned. 113 | 114 | 115 | ### Passing cmdline args to the Runner 116 | Note that the core-lightning runner, in `__init__`, converts 117 | cmdline `runner_args` into a `startup_flag` array, which are then 118 | passed to the node at `start` 119 | 120 | Relevant portion from `clightning.py/Runner#__init__` 121 | ``` 122 | self.startup_flags = [] 123 | for flag in config.getoption("runner_args"): 124 | self.startup_flags.append("--{}".format(flag)) 125 | ``` 126 | 127 | 128 | Relevant portion from `clightning.py/Runner#start` 129 | ``` 130 | self.proc = subprocess.Popen(['{}/lightningd/lightningd'.f... 131 | '--network=regtest', 132 | '--bitcoin-rpcuser=rpcuser', 133 | '--bitcoin-rpcpassword=rpcpass', 134 | '--bitcoin-rpcport={}'.format(self.bitcoind.port), 135 | '--log-level=debug', 136 | '--log-file=log'] 137 | + self.startup_flags) 138 | ``` 139 | 140 | 141 | 142 | ### Initializing the funding for the tests with `submitblock` 143 | 144 | Note that the bitcoind backend in `lnprototest/backend/bitcoind.py` 145 | creates an initial block spendable by the privkey 146 | `cUB4V7VCk6mX32981TWviQVLkj3pa2zBcXrjMZ9QwaZB5Kojhp59`, then an 147 | additional 100 blocks so it's mature. `tests/helpers.py` has 148 | `tx_spendable` which spends this into several useful outputs, and many 149 | tests rely on this. 150 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2024 Rusty Russell (Blockstream), Vincenzo Palazzo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | #! /usr/bin/make 2 | 3 | PYTHONFILES := $(shell find * ! -path "build/*" ! -path "venv/*" -name '*.py') 4 | POSSIBLE_PYTEST_NAMES=pytest-3 pytest3 pytest 5 | PYTEST := $(shell for p in $(POSSIBLE_PYTEST_NAMES); do if type $$p > /dev/null; then echo $$p; break; fi done) 6 | TEST_DIR=tests 7 | 8 | default: check-source check check-quotes 9 | 10 | check-pytest-found: 11 | @if [ -z "$(PYTEST)" ]; then echo "Cannot find any pytest: $(POSSIBLE_PYTEST_NAMES)" >&2; exit 1; fi 12 | 13 | check: check-pytest-found 14 | $(PYTEST) $(PYTEST_ARGS) $(TEST_DIR) 15 | 16 | check-source: check-fmt check-mypy check-internal-tests 17 | 18 | check-mypy: 19 | mypy --ignore-missing-imports --disallow-untyped-defs --disallow-incomplete-defs $(PYTHONFILES) 20 | 21 | check-internal-tests: check-pytest-found 22 | $(PYTEST) `find lnprototest -name '*.py'` 23 | 24 | check-quotes/%: % 25 | tools/check_quotes.py $* 26 | 27 | check-quotes: $(PYTHONFILES:%=check-quotes/%) 28 | 29 | check-fmt: 30 | black --check . 31 | 32 | fmt: 33 | black . 34 | 35 | TAGS: 36 | etags `find . -name '*.py'` 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

lnprototest

3 | 4 |

5 | a Testsuite for the Lightning Network Protocol 6 |

7 | 8 |

9 | Project Homepage 10 |

11 | 12 | 13 | GitHub Workflow Status (branch) 14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | lnprototest is a set of test helpers written in Python3, designed to 23 | make it easy to write new tests when you propose changes to the 24 | lightning network protocol, as well as test existing implementations. 25 | 26 | ## Install requirements 27 | 28 | To install the necessary dependences 29 | 30 | ```bash 31 | pip3 install poetry 32 | poetry shell 33 | poetry install 34 | ``` 35 | 36 | Well, now we can run the test 37 | 38 | ## Running test 39 | 40 | The simplest way to run is with the "dummy" runner: 41 | 42 | make check 43 | 44 | Here are some other useful pytest options: 45 | 46 | 1. `-n8` to run 8-way parallel. 47 | 2. `-x` to stop on the first failure. 48 | 3. `--pdb` to enter the debugger on first failure. 49 | 4. `--trace` to enter the debugger on every test. 50 | 5. `-k foo` to only run tests with 'foo' in their name. 51 | 6. `tests/test_bolt1-01-init.py` to only run tests in that file. 52 | 7. `tests/test_bolt1-01-init.py::test_init` to only run that test. 53 | 8. `--log-cli-level={LEVEL_NAME}` to enable the logging during the test execution. 54 | 55 | ### Running Against A Real Node. 56 | 57 | The more useful way to run is to use an existing implementation. So 58 | far, core-lightning is supported. You will need: 59 | 60 | 1. `bitcoind` installed, and in your path. 61 | 2. [`lightningd`](https://github.com/ElementsProject/lightning/) compiled with 62 | `--enable-developer`. By default the source directory should be 63 | `../lightning` relative to this directory, otherwise use 64 | `export LIGHTNING_SRC=dirname`. 65 | 3. Install any python requirements by 66 | `pip3 install -r lnprototest/clightning/requirements.txt`. 67 | 68 | Then you can run 69 | 70 | make check PYTEST_ARGS='--runner=lnprototest.clightning.Runner' 71 | 72 | or directly: 73 | 74 | pytest --runner=lnprototest.clightning.Runner 75 | 76 | # Further Work 77 | 78 | If you want to write new tests or new backends, see [HACKING.md](HACKING.md). 79 | 80 | Let's keep the sats flowing! 81 | 82 | Rusty. 83 | -------------------------------------------------------------------------------- /changelog.json: -------------------------------------------------------------------------------- 1 | { 2 | "package_name": "lnprototest", 3 | "version": "v0.0.5", 4 | "api": { 5 | "name": "github", 6 | "repository": "rustyrussell/lnprototest", 7 | "branch": "master" 8 | }, 9 | "generation_method": { 10 | "name": "semver-v2", 11 | "header_filter": false 12 | }, 13 | "serialization_method": { 14 | "name": "md" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /docker/Dockerfile.clightning: -------------------------------------------------------------------------------- 1 | FROM ubuntu:22.04 2 | LABEL mantainer="Vincenzo Palazzo vincenzopalazzodev@gmail.com" 3 | 4 | WORKDIR /work 5 | 6 | ENV BITCOIN_VERSION=23.0 7 | ENV DEBIAN_FRONTEND=noninteractive 8 | 9 | RUN apt-get -qq update && \ 10 | apt-get -qq install --no-install-recommends --allow-unauthenticated -yy \ 11 | autoconf \ 12 | automake \ 13 | clang \ 14 | cppcheck \ 15 | docbook-xml \ 16 | shellcheck \ 17 | eatmydata \ 18 | software-properties-common \ 19 | build-essential \ 20 | autoconf \ 21 | locales \ 22 | libtool \ 23 | libprotobuf-c-dev \ 24 | libsqlite3-dev \ 25 | libgmp-dev \ 26 | git \ 27 | python3 \ 28 | valgrind \ 29 | net-tools \ 30 | python3-mako \ 31 | python3-pip \ 32 | python3-setuptools \ 33 | python-pkg-resources \ 34 | python3-dev \ 35 | virtualenv \ 36 | shellcheck \ 37 | libxml2-utils \ 38 | wget \ 39 | gettext \ 40 | xsltproc \ 41 | zlib1g-dev \ 42 | jq && \ 43 | rm -rf /var/lib/apt/lists/* 44 | 45 | ENV LANGUAGE=en_US.UTF-8 46 | ENV LANG=en_US.UTF-8 47 | ENV LC_ALL=en_US.UTF-8 48 | RUN locale-gen en_US.UTF-8 && dpkg-reconfigure locales 49 | 50 | RUN cd /tmp/ && \ 51 | wget https://bitcoincore.org/bin/bitcoin-core-$BITCOIN_VERSION/bitcoin-$BITCOIN_VERSION-x86_64-linux-gnu.tar.gz -O bitcoin.tar.gz && \ 52 | tar -xvzf bitcoin.tar.gz && \ 53 | mv /tmp/bitcoin-$BITCOIN_VERSION/bin/bitcoin* /usr/local/bin/ && \ 54 | rm -rf bitcoin.tar.gz /tmp/bitcoin-$BITCOIN_VERSION 55 | 56 | RUN pip3 install -U pip && \ 57 | pip3 install -U poetry 58 | 59 | RUN git config --global user.name "John Doe" && \ 60 | git config --global user.email johndoe@example.com && \ 61 | git clone https://github.com/ElementsProject/lightning.git && \ 62 | # FIXME: cln 24.05 is the last version that works with the current lnprototest. 63 | cd lightning && git checkout v24.05 && \ 64 | pip3 install mako --break-system-packages && pip3 install grpcio-tools --break-system-packages && \ 65 | ./configure && \ 66 | make -j$(nproc) 67 | 68 | RUN mkdir lnprototest 69 | 70 | COPY . lnprototest 71 | 72 | RUN cd lnprototest && \ 73 | poetry config virtualenvs.create false && \ 74 | poetry install 75 | RUN cd lnprototest && ls -lha 76 | 77 | CMD ["./lnprototest/docker/entrypoint.sh"] 78 | -------------------------------------------------------------------------------- /docker/Dockerfile.ldk: -------------------------------------------------------------------------------- 1 | FROM ubuntu:22.04 2 | LABEL maintainer="Prakhar Saxena prakharrsaxena@gmail.com" 3 | 4 | ENV TZ=Europe/Minsk 5 | ENV BITCOIN_VERSION=23.0 6 | 7 | # Set timezone 8 | RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone 9 | 10 | # Install Ubuntu utilities and essential dependencies 11 | RUN apt-get update && apt-get install -y \ 12 | software-properties-common \ 13 | build-essential \ 14 | curl wget jq \ 15 | git python3 python3-pip 16 | 17 | # Install Rust 18 | RUN curl https://sh.rustup.rs -sSf | bash -s -- -y 19 | ENV PATH="/root/.cargo/bin:${PATH}" 20 | RUN echo 'source $HOME/.cargo/env' >> $HOME/.bashrc 21 | 22 | # Install Bitcoin Core 23 | RUN cd /tmp/ && \ 24 | wget https://bitcoincore.org/bin/bitcoin-core-$BITCOIN_VERSION/bitcoin-$BITCOIN_VERSION-x86_64-linux-gnu.tar.gz -O bitcoin.tar.gz && \ 25 | tar -xvzf bitcoin.tar.gz && \ 26 | mv /tmp/bitcoin-$BITCOIN_VERSION/bin/bitcoin* /usr/local/bin/ && \ 27 | rm -rf bitcoin.tar.gz /tmp/bitcoin-$BITCOIN_VERSION 28 | 29 | # Clone and build LDK-Sample 30 | RUN git clone https://github.com/Psycho-Pirate/ldk-sample.git 31 | RUN cd ldk-sample && cargo build 32 | 33 | # Install Poetry 34 | RUN pip install poetry 35 | 36 | # Set PYTHONPATH environment variable 37 | ENV LDK_SRC="/ldk-sample" 38 | 39 | # Set workdir and copy project files 40 | RUN mkdir lnprototest 41 | COPY . lnprototest 42 | 43 | RUN cd lnprototest && \ 44 | poetry install && \ 45 | poetry run pip install ldk-lnprototest 46 | RUN cd lnprototest && ls -lha 47 | RUN chmod +x ./lnprototest/docker/ldk-entrypoint.sh 48 | 49 | CMD ["./lnprototest/docker/ldk-entrypoint.sh"] 50 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | lightningd: 5 | build: 6 | context: ../ 7 | dockerfile: ./docker/Dockerfile.${LN_IMPL} -------------------------------------------------------------------------------- /docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | cd lnprototest || exit 3 | 4 | for i in range{0..5}; 5 | do 6 | if make check PYTEST_ARGS='--runner=lnprototest.clightning.Runner -n8 --dist=loadfile --log-cli-level=DEBUG'; then 7 | echo "iteration $i succeeded" 8 | else 9 | echo "iteration $i failed" 10 | exit 1 11 | fi 12 | done -------------------------------------------------------------------------------- /docker/ldk-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | cd lnprototest || exit 3 | 4 | for i in range{0..5}; 5 | do 6 | if poetry run make check PYTEST_ARGS="--runner=ldk_lnprototest.Runner -n8 --dist=loadfile --log-cli-level=DEBUG"; then 7 | echo "iteration $i succeeded" 8 | else 9 | echo "iteration $i failed" 10 | exit 1 11 | fi 12 | done 13 | -------------------------------------------------------------------------------- /lnprototest/__init__.py: -------------------------------------------------------------------------------- 1 | """lnprototest: a framework and test suite for checking lightning spec protocol compliance. 2 | 3 | This package is unusual, in that its main purpose is to carry the unit 4 | tests, which can be run against a Lightning node implementation, using 5 | an adapter called a 'Runner'. Two runners are included: the 6 | DummyRunner which is the default, and mainly useful to sanity check 7 | the tests themselves, and clightning.Runner. 8 | 9 | The documentation for the classes themselves should cover much of the 10 | reference material, and the tutorial should get you started. 11 | 12 | """ 13 | 14 | from .errors import EventError, SpecFileError 15 | 16 | from .event import ( 17 | Event, 18 | Connect, 19 | Disconnect, 20 | Msg, 21 | RawMsg, 22 | ExpectMsg, 23 | MustNotMsg, 24 | Block, 25 | ExpectTx, 26 | FundChannel, 27 | InitRbf, 28 | Invoice, 29 | AddHtlc, 30 | CheckEq, 31 | ExpectError, 32 | ResolvableInt, 33 | ResolvableStr, 34 | Resolvable, 35 | ResolvableBool, 36 | msat, 37 | negotiated, 38 | DualFundAccept, 39 | Wait, 40 | CloseChannel, 41 | ExpectDisconnect, 42 | ) 43 | 44 | from .structure import Sequence, OneOf, AnyOrder, TryAll 45 | 46 | from .runner import ( 47 | Runner, 48 | Conn, 49 | remote_revocation_basepoint, 50 | remote_payment_basepoint, 51 | remote_delayed_payment_basepoint, 52 | remote_htlc_basepoint, 53 | remote_per_commitment_point, 54 | remote_per_commitment_secret, 55 | remote_funding_pubkey, 56 | remote_funding_privkey, 57 | ) 58 | from .dummyrunner import DummyRunner 59 | from .namespace import ( 60 | peer_message_namespace, 61 | namespace, 62 | assign_namespace, 63 | make_namespace, 64 | ) 65 | from .bitfield import bitfield, has_bit, bitfield_len 66 | from .signature import SigType, Sig 67 | from .keyset import KeySet 68 | from .commit_tx import Commit, HTLC, UpdateCommit 69 | from .utils import ( 70 | Side, 71 | privkey_expand, 72 | wait_for, 73 | LightningUtils, 74 | ScriptType, 75 | BitcoinUtils, 76 | ) 77 | from .funding import ( 78 | AcceptFunding, 79 | CreateFunding, 80 | CreateDualFunding, 81 | Funding, 82 | AddInput, 83 | AddOutput, 84 | FinalizeFunding, 85 | AddWitnesses, 86 | ) 87 | from .proposals import dual_fund_csv, channel_type_csv 88 | 89 | __all__ = [ 90 | "EventError", 91 | "SpecFileError", 92 | "Resolvable", 93 | "ResolvableInt", 94 | "ResolvableStr", 95 | "ResolvableBool", 96 | "Event", 97 | "Connect", 98 | "Disconnect", 99 | "DualFundAccept", 100 | "CreateDualFunding", 101 | "AddInput", 102 | "AddOutput", 103 | "FinalizeFunding", 104 | "AddWitnesses", 105 | "Msg", 106 | "RawMsg", 107 | "ExpectMsg", 108 | "Block", 109 | "ExpectTx", 110 | "FundChannel", 111 | "InitRbf", 112 | "Invoice", 113 | "AddHtlc", 114 | "ExpectError", 115 | "Sequence", 116 | "OneOf", 117 | "AnyOrder", 118 | "TryAll", 119 | "CheckEq", 120 | "MustNotMsg", 121 | "SigType", 122 | "Sig", 123 | "DummyRunner", 124 | "Runner", 125 | "Conn", 126 | "KeySet", 127 | "peer_message_namespace", 128 | "namespace", 129 | "assign_namespace", 130 | "make_namespace", 131 | "bitfield", 132 | "has_bit", 133 | "bitfield_len", 134 | "msat", 135 | "negotiated", 136 | "remote_revocation_basepoint", 137 | "remote_payment_basepoint", 138 | "remote_delayed_payment_basepoint", 139 | "remote_htlc_basepoint", 140 | "remote_per_commitment_point", 141 | "remote_per_commitment_secret", 142 | "remote_funding_pubkey", 143 | "remote_funding_privkey", 144 | "Commit", 145 | "HTLC", 146 | "UpdateCommit", 147 | "Side", 148 | "AcceptFunding", 149 | "CreateFunding", 150 | "Funding", 151 | "regtest_hash", 152 | "privkey_expand", 153 | "Wait", 154 | "dual_fund_csv", 155 | "channel_type_csv", 156 | "wait_for", 157 | "CloseChannel", 158 | "ExpectDisconnect", 159 | ] 160 | -------------------------------------------------------------------------------- /lnprototest/backend/__init__.py: -------------------------------------------------------------------------------- 1 | from .backend import Backend 2 | from .bitcoind import Bitcoind 3 | 4 | __all__ = ["Backend", "Bitcoind"] 5 | -------------------------------------------------------------------------------- /lnprototest/backend/backend.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/python3 2 | # This script exercises the backend implementation 3 | 4 | # Released by Rusty Russell under CC0: 5 | # https://creativecommons.org/publicdomain/zero/1.0/ 6 | from abc import ABC, abstractmethod 7 | 8 | 9 | class Backend(ABC): 10 | """ 11 | Generic implementation of Bitcoin backend 12 | This is useful when the LN node uses different type 13 | of bitcoin backend. 14 | """ 15 | 16 | @abstractmethod 17 | def start(self) -> None: 18 | pass 19 | 20 | @abstractmethod 21 | def stop(self) -> None: 22 | pass 23 | 24 | @abstractmethod 25 | def restart(self) -> None: 26 | pass 27 | -------------------------------------------------------------------------------- /lnprototest/backend/bitcoind.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # This script exercises the core-lightning implementation 3 | 4 | # Released by Rusty Russell under CC0: 5 | # https://creativecommons.org/publicdomain/zero/1.0/ 6 | 7 | import os 8 | import shutil 9 | import subprocess 10 | import logging 11 | import socket 12 | 13 | from contextlib import closing 14 | from typing import Any, Callable, Optional 15 | from bitcoin.rpc import RawProxy 16 | from .backend import Backend 17 | 18 | 19 | class BitcoinProxy: 20 | """Wrapper for BitcoinProxy to reconnect. 21 | 22 | Long wait times between calls to the Bitcoin RPC could result in 23 | `bitcoind` closing the connection, so here we just create 24 | throwaway connections. This is easier than to reach into the RPC 25 | library to close, reopen and reauth upon failure. 26 | """ 27 | 28 | def __init__(self, btc_conf_file: str, *args: Any, **kwargs: Any): 29 | self.btc_conf_file = btc_conf_file 30 | 31 | def __getattr__(self, name: str) -> Callable: 32 | if name.startswith("__") and name.endswith("__"): 33 | # Python internal stuff 34 | raise AttributeError 35 | 36 | def f(*args: Any) -> Callable: 37 | self.__proxy = RawProxy(btc_conf_file=self.btc_conf_file) 38 | 39 | logging.debug( 40 | "Calling {name} with arguments {args}".format(name=name, args=args) 41 | ) 42 | res = self.__proxy._call(name, *args) 43 | logging.debug("Result for {name} call: {res}".format(name=name, res=res)) 44 | return res 45 | 46 | # Make debuggers show rather than > 48 | f.__name__ = name 49 | return f 50 | 51 | 52 | class Bitcoind(Backend): 53 | """Starts regtest bitcoind on an ephemeral port, and returns the RPC proxy""" 54 | 55 | def __init__(self, basedir: str, with_wallet: Optional[str] = None): 56 | self.with_wallet = with_wallet 57 | self.rpc = None 58 | self.proc = None 59 | self.base_dir = basedir 60 | logging.debug(f"Base dir is {basedir}") 61 | self.bitcoin_dir = os.path.join(basedir, "bitcoind") 62 | self.bitcoin_conf = os.path.join(self.bitcoin_dir, "bitcoin.conf") 63 | self.cmd_line = [ 64 | "bitcoind", 65 | "-datadir={}".format(self.bitcoin_dir), 66 | "-server", 67 | "-regtest", 68 | "-logtimestamps", 69 | "-nolisten", 70 | ] 71 | self.btc_version = None 72 | 73 | def __reserve(self) -> int: 74 | """ 75 | When python asks for a free port from the os, it is possible that 76 | with concurrent access, the port that is picked is a port that is not free 77 | anymore when we go to bind the daemon like bitcoind port. 78 | 79 | Source: https://stackoverflow.com/questions/1365265/on-localhost-how-do-i-pick-a-free-port-number 80 | """ 81 | with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: 82 | s.bind(("", 0)) 83 | s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 84 | return s.getsockname()[1] 85 | 86 | def __init_bitcoin_conf(self): 87 | """Init the bitcoin core directory with all the necessary information 88 | to startup the node""" 89 | if not os.path.exists(self.bitcoin_dir): 90 | os.makedirs(self.bitcoin_dir) 91 | logging.debug(f"Creating {self.bitcoin_dir} directory") 92 | self.port = self.__reserve() 93 | logging.debug("Port is {}, dir is {}".format(self.port, self.bitcoin_dir)) 94 | # For after 0.16.1 (eg. 3f398d7a17f136cd4a67998406ca41a124ae2966), this 95 | # needs its own [regtest] section. 96 | logging.debug(f"Writing bitcoin conf file at {self.bitcoin_conf}") 97 | with open(self.bitcoin_conf, "w") as f: 98 | f.write("regtest=1\n") 99 | f.write("rpcuser=rpcuser\n") 100 | f.write("rpcpassword=rpcpass\n") 101 | f.write("[regtest]\n") 102 | f.write("rpcport={}\n".format(self.port)) 103 | self.rpc = BitcoinProxy(btc_conf_file=self.bitcoin_conf) 104 | 105 | def __version_compatibility(self) -> None: 106 | """ 107 | This method tries to manage the compatibility between 108 | different versions of Bitcoin Core implementation. 109 | 110 | This method could sometimes be useful when it is necessary to 111 | run the test with a different version of Bitcoin core. 112 | """ 113 | if self.rpc is None: 114 | # Sanity check 115 | raise ValueError("bitcoind not initialized") 116 | 117 | self.btc_version = self.rpc.getnetworkinfo()["version"] 118 | assert self.btc_version is not None 119 | logging.debug("Bitcoin Core version {}".format(self.btc_version)) 120 | if self.btc_version >= 210000: 121 | # Maintains the compatibility between wallet 122 | # different ln implementation can use the main wallet (?) 123 | self.rpc.createwallet( 124 | "main" if self.with_wallet is None else self.with_wallet 125 | ) # Automatically loads 126 | 127 | def __is__bitcoind_ready(self) -> bool: 128 | """Check if bitcoind is ready during the execution""" 129 | if self.proc is None: 130 | # Sanity check 131 | raise ValueError("bitcoind not initialized") 132 | 133 | # Wait for it to startup. 134 | while b"Done loading" not in self.proc.stdout.readline(): 135 | pass 136 | return True 137 | 138 | def start(self) -> None: 139 | if self.rpc is None: 140 | self.__init_bitcoin_conf() 141 | # TODO: We can move this to a single call and not use Popen 142 | self.proc = subprocess.Popen(self.cmd_line, stdout=subprocess.PIPE) 143 | assert self.proc.stdout 144 | 145 | # Wait for it to startup. 146 | while not self.__is__bitcoind_ready(): 147 | logging.debug("Bitcoin core is loading") 148 | 149 | self.__version_compatibility() 150 | # Block #1. 151 | # Privkey the coinbase spends to: 152 | # cUB4V7VCk6mX32981TWviQVLkj3pa2zBcXrjMZ9QwaZB5Kojhp59 153 | self.rpc.submitblock( 154 | "0000002006226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f84591a56720aabc8023cecf71801c5e0f9d049d0c550ab42412ad12a67d89f3a3dbb6c60ffff7f200400000001020000000001010000000000000000000000000000000000000000000000000000000000000000ffffffff03510101ffffffff0200f2052a0100000016001419f5016f07fe815f611df3a2a0802dbd74e634c40000000000000000266a24aa21a9ede2f61c3f71d1defd3fa999dfa36953755c690689799962b48bebd836974e8cf90120000000000000000000000000000000000000000000000000000000000000000000000000" 155 | ) 156 | self.rpc.generatetoaddress(100, self.rpc.getnewaddress()) 157 | 158 | def stop(self) -> None: 159 | self.rpc.stop() 160 | self.proc.kill() 161 | shutil.rmtree(os.path.join(self.bitcoin_dir, "regtest")) 162 | 163 | def restart(self) -> None: 164 | # Only restart if we have to. 165 | if self.rpc.getblockcount() != 101 or self.rpc.getrawmempool() != []: 166 | self.stop() 167 | self.start() 168 | -------------------------------------------------------------------------------- /lnprototest/bitfield.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/python3 2 | from typing import Union, List 3 | 4 | 5 | def bitfield_len(bitfield: Union[List[int], str]) -> int: 6 | """Return length of this field in bits (assuming it's a bitfield!)""" 7 | if isinstance(bitfield, str): 8 | return len(bytes.fromhex(bitfield)) * 8 9 | else: 10 | return len(bitfield) * 8 11 | 12 | 13 | def has_bit(bitfield: Union[List[int], str], bitnum: int) -> bool: 14 | """Test bit in this bitfield (little-endian, as per BOLTs)""" 15 | bitlen = bitfield_len(bitfield) 16 | if bitnum >= bitlen: 17 | return False 18 | 19 | # internal to a msg, it's a list of int. 20 | if isinstance(bitfield, str): 21 | byte = bytes.fromhex(bitfield)[bitlen // 8 - 1 - bitnum // 8] 22 | else: 23 | byte = bitfield[bitlen // 8 - 1 - bitnum // 8] 24 | 25 | if (byte & (1 << (bitnum % 8))) != 0: 26 | return True 27 | else: 28 | return False 29 | 30 | 31 | def bitfield(*args: int) -> str: 32 | """Create a bitfield hex value with these bit numbers set""" 33 | bytelen = (max(args) + 8) // 8 34 | bfield = bytearray(bytelen) 35 | for bitnum in args: 36 | bfield[bytelen - 1 - bitnum // 8] |= 1 << (bitnum % 8) 37 | return bfield.hex() 38 | -------------------------------------------------------------------------------- /lnprototest/clightning/__init__.py: -------------------------------------------------------------------------------- 1 | """Runner for the core-lightning implementation. 2 | 3 | This is adapted from the older testcases, so it is overly prescriptive 4 | of how the node is configured. A more modern implementation would simply 5 | reach into the node and derive the private keys it's using, rather than 6 | use hacky --dev options to override it. 7 | 8 | We could also factor out the bitcoind implementation, which is really 9 | independent. 10 | 11 | Important environment variables include TIMEOUT which sets how long we 12 | wait for responses (default, 30 seconds), and LIGHTNING_SRC which indicates 13 | where the binaries are (default ../lightning). 14 | 15 | """ 16 | 17 | from .clightning import Runner 18 | 19 | __all__ = ["Runner"] 20 | -------------------------------------------------------------------------------- /lnprototest/clightning/requirements.txt: -------------------------------------------------------------------------------- 1 | pyln-client 2 | -------------------------------------------------------------------------------- /lnprototest/dummyrunner.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/python3 2 | # #### Dummy runner which you should replace with real one. #### 3 | import io 4 | from .runner import Runner, Conn 5 | from .event import Event, ExpectMsg, MustNotMsg 6 | from typing import List, Optional 7 | from .keyset import KeySet 8 | from pyln.proto.message import ( 9 | Message, 10 | FieldType, 11 | DynamicArrayType, 12 | EllipsisArrayType, 13 | SizedArrayType, 14 | ) 15 | from typing import Any 16 | 17 | 18 | class DummyRunner(Runner): 19 | def __init__(self, config: Any): 20 | super().__init__(config) 21 | 22 | def _is_dummy(self) -> bool: 23 | """The DummyRunner returns True here, as it can't do some things""" 24 | return True 25 | 26 | def get_keyset(self) -> KeySet: 27 | return KeySet( 28 | revocation_base_secret="11", 29 | payment_base_secret="12", 30 | htlc_base_secret="14", 31 | delayed_payment_base_secret="13", 32 | shachain_seed="FF" * 32, 33 | ) 34 | 35 | def add_startup_flag(self, flag: str) -> None: 36 | if self.config.getoption("verbose"): 37 | print("[ADD STARTUP FLAG {}]".format(flag)) 38 | return 39 | 40 | def get_node_privkey(self) -> str: 41 | return "01" 42 | 43 | def get_node_bitcoinkey(self) -> str: 44 | return "10" 45 | 46 | def has_option(self, optname: str) -> Optional[str]: 47 | return None 48 | 49 | def start(self) -> None: 50 | self.blockheight = 102 51 | 52 | def stop(self, print_logs: bool = False) -> None: 53 | pass 54 | 55 | def restart(self) -> None: 56 | super().restart() 57 | if self.config.getoption("verbose"): 58 | print("[RESTART]") 59 | self.blockheight = 102 60 | 61 | def connect(self, event: Event, connprivkey: str) -> None: 62 | if self.config.getoption("verbose"): 63 | print("[CONNECT {} {}]".format(event, connprivkey)) 64 | self.add_conn(Conn(connprivkey)) 65 | 66 | def getblockheight(self) -> int: 67 | return self.blockheight 68 | 69 | def trim_blocks(self, newheight: int) -> None: 70 | if self.config.getoption("verbose"): 71 | print("[TRIMBLOCK TO HEIGHT {}]".format(newheight)) 72 | self.blockheight = newheight 73 | 74 | def add_blocks(self, event: Event, txs: List[str], n: int) -> None: 75 | if self.config.getoption("verbose"): 76 | print("[ADDBLOCKS {} WITH {} TXS]".format(n, len(txs))) 77 | self.blockheight += n 78 | 79 | def disconnect(self, event: Event, conn: Conn) -> None: 80 | super().disconnect(event, conn) 81 | if self.config.getoption("verbose"): 82 | print("[DISCONNECT {}]".format(conn)) 83 | 84 | def recv(self, event: Event, conn: Conn, outbuf: bytes) -> None: 85 | if self.config.getoption("verbose"): 86 | print("[RECV {} {}]".format(event, outbuf.hex())) 87 | 88 | def fundchannel( 89 | self, 90 | event: Event, 91 | conn: Conn, 92 | amount: int, 93 | feerate: int = 253, 94 | expect_fail: bool = False, 95 | ) -> None: 96 | if self.config.getoption("verbose"): 97 | print( 98 | "[FUNDCHANNEL TO {} for {} at feerate {}. Expect fail? {}]".format( 99 | conn, amount, feerate, expect_fail 100 | ) 101 | ) 102 | 103 | def init_rbf( 104 | self, 105 | event: Event, 106 | conn: Conn, 107 | channel_id: str, 108 | amount: int, 109 | utxo_txid: str, 110 | utxo_outnum: int, 111 | feerate: int, 112 | ) -> None: 113 | if self.config.getoption("verbose"): 114 | print( 115 | "[INIT_RBF TO {} (channel {}) for {} at feerate {}. {}:{}".format( 116 | conn, channel_id, amount, feerate, utxo_txid, utxo_outnum 117 | ) 118 | ) 119 | 120 | def invoice(self, event: Event, amount: int, preimage: str) -> None: 121 | if self.config.getoption("verbose"): 122 | print("[INVOICE for {} with PREIMAGE {}]".format(amount, preimage)) 123 | 124 | def accept_add_fund(self, event: Event) -> None: 125 | if self.config.getoption("verbose"): 126 | print("[ACCEPT_ADD_FUND]") 127 | 128 | def addhtlc(self, event: Event, conn: Conn, amount: int, preimage: str) -> None: 129 | if self.config.getoption("verbose"): 130 | print( 131 | "[ADDHTLC TO {} for {} with PREIMAGE {}]".format(conn, amount, preimage) 132 | ) 133 | 134 | @staticmethod 135 | def fake_field(ftype: FieldType) -> str: 136 | if isinstance(ftype, DynamicArrayType) or isinstance(ftype, EllipsisArrayType): 137 | # Byte arrays are literal hex strings 138 | if ftype.elemtype.name == "byte": 139 | return "" 140 | return "[]" 141 | elif isinstance(ftype, SizedArrayType): 142 | # Byte arrays are literal hex strings 143 | if ftype.elemtype.name == "byte": 144 | return "00" * ftype.arraysize 145 | return ( 146 | "[" 147 | + ",".join([DummyRunner.fake_field(ftype.elemtype)] * ftype.arraysize) 148 | + "]" 149 | ) 150 | elif ftype.name in ( 151 | "byte", 152 | "u8", 153 | "u16", 154 | "u32", 155 | "u64", 156 | "tu16", 157 | "tu32", 158 | "tu64", 159 | "bigsize", 160 | "varint", 161 | ): 162 | return "0" 163 | elif ftype.name in ("chain_hash", "channel_id", "sha256"): 164 | return "00" * 32 165 | elif ftype.name == "point": 166 | return "038f1573b4238a986470d250ce87c7a91257b6ba3baf2a0b14380c4e1e532c209d" 167 | elif ftype.name == "short_channel_id": 168 | return "0x0x0" 169 | elif ftype.name == "signature": 170 | return "01" * 64 171 | else: 172 | raise NotImplementedError( 173 | "don't know how to fake {} type!".format(ftype.name) 174 | ) 175 | 176 | def get_output_message(self, conn: Conn, event: ExpectMsg) -> Optional[bytes]: 177 | if self.config.getoption("verbose"): 178 | print("[GET_OUTPUT_MESSAGE {}]".format(conn)) 179 | 180 | # We make the message they were expecting. 181 | msg = Message(event.msgtype, **event.resolve_args(self, event.kwargs)) 182 | 183 | # Fake up the other fields. 184 | for m in msg.missing_fields(): 185 | ftype = msg.messagetype.find_field(m.name) 186 | msg.set_field(m.name, self.fake_field(ftype.fieldtype)) 187 | 188 | binmsg = io.BytesIO() 189 | msg.write(binmsg) 190 | return binmsg.getvalue() 191 | 192 | def expect_tx(self, event: Event, txid: str) -> None: 193 | if self.config.getoption("verbose"): 194 | print("[EXPECT-TX {}]".format(txid)) 195 | 196 | def check_error(self, event: Event, conn: Conn) -> Optional[str]: 197 | super().check_error(event, conn) 198 | if self.config.getoption("verbose"): 199 | print("[CHECK-ERROR {}]".format(event)) 200 | return "Dummy error" 201 | 202 | def check_final_error( 203 | self, 204 | event: Event, 205 | conn: Conn, 206 | expected: bool, 207 | must_not_events: List[MustNotMsg], 208 | ) -> None: 209 | pass 210 | 211 | def close_channel(self, channel_id: str) -> bool: 212 | if self.config.getoption("verbose"): 213 | print("[CLOSE-CHANNEL {}]".format(channel_id)) 214 | return True 215 | 216 | def is_running(self) -> bool: 217 | return True 218 | 219 | def teardown(self): 220 | pass 221 | -------------------------------------------------------------------------------- /lnprototest/errors.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | if TYPE_CHECKING: 4 | from .event import Event 5 | 6 | 7 | class EventError(Exception): 8 | """Error thrown when the runner fails in some way""" 9 | 10 | def __init__(self, event: "Event", message: str): 11 | self.eventpath = [event] 12 | self.message = message 13 | 14 | def add_path(self, event: "Event") -> None: 15 | self.eventpath = [event] + self.eventpath 16 | 17 | def path_to_str(self) -> str: 18 | result = "[" 19 | for event in self.eventpath: 20 | result += f"{event}," 21 | return f"{result}]" 22 | 23 | def __str__(self) -> str: 24 | return f"`{self.message}` on event {self.path_to_str()}" 25 | 26 | 27 | class SpecFileError(Exception): 28 | """Error thrown when the specification file has an error""" 29 | 30 | def __init__(self, event: "Event", message: str): 31 | self.event = event 32 | self.message = message 33 | -------------------------------------------------------------------------------- /lnprototest/keyset.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/python3 2 | # FIXME: clean this up for use as pyln.proto.tx 3 | import coincurve 4 | import hashlib 5 | 6 | 7 | class KeySet(object): 8 | def __init__( 9 | self, 10 | revocation_base_secret: str, 11 | payment_base_secret: str, 12 | htlc_base_secret: str, 13 | delayed_payment_base_secret: str, 14 | shachain_seed: str, 15 | ): 16 | from .utils import privkey_expand, check_hex 17 | 18 | self.revocation_base_secret = privkey_expand(revocation_base_secret) 19 | self.payment_base_secret = privkey_expand(payment_base_secret) 20 | self.htlc_base_secret = privkey_expand(htlc_base_secret) 21 | self.delayed_payment_base_secret = privkey_expand(delayed_payment_base_secret) 22 | self.shachain_seed = bytes.fromhex(check_hex(shachain_seed, 64)) 23 | 24 | def raw_payment_basepoint(self) -> coincurve.PublicKey: 25 | return coincurve.PublicKey.from_secret(self.payment_base_secret.secret) 26 | 27 | def payment_basepoint(self) -> str: 28 | return self.raw_payment_basepoint().format().hex() 29 | 30 | def raw_revocation_basepoint(self) -> coincurve.PublicKey: 31 | return coincurve.PublicKey.from_secret(self.revocation_base_secret.secret) 32 | 33 | def revocation_basepoint(self) -> str: 34 | return self.raw_revocation_basepoint().format().hex() 35 | 36 | def raw_delayed_payment_basepoint(self) -> coincurve.PublicKey: 37 | return coincurve.PublicKey.from_secret(self.delayed_payment_base_secret.secret) 38 | 39 | def delayed_payment_basepoint(self) -> str: 40 | return self.raw_delayed_payment_basepoint().format().hex() 41 | 42 | def raw_htlc_basepoint(self) -> coincurve.PublicKey: 43 | return coincurve.PublicKey.from_secret(self.htlc_base_secret.secret) 44 | 45 | def htlc_basepoint(self) -> str: 46 | return self.raw_htlc_basepoint().format().hex() 47 | 48 | def raw_per_commit_secret(self, n: int) -> coincurve.PrivateKey: 49 | # BOLT #3: 50 | # The first secret used: 51 | # - MUST be index 281474976710655, 52 | # - and from there, the index is decremented. 53 | if n > 281474976710655: 54 | raise ValueError("48 bits is all you get!") 55 | index = 281474976710655 - n 56 | 57 | # BOLT #3: 58 | # generate_from_seed(seed, I): 59 | # P = seed 60 | # for B in 47 down to 0: 61 | # if B set in I: 62 | # flip(B) in P 63 | # P = SHA256(P) 64 | # return P 65 | # ``` 66 | 67 | # FIXME: This is the updated wording from PR #779 68 | # Where "flip(B)" alternates the (B mod 8)'th bit of the (B div 8)'th 69 | # byte of the value. So, "flip(0) in e3b0..." is "e2b0...", and 70 | # "flip(10) in "e3b0..." is "e3b4". 71 | P = bytearray(self.shachain_seed) 72 | for B in range(47, -1, -1): 73 | if ((1 << B) & index) != 0: 74 | P[B // 8] ^= 1 << (B % 8) 75 | P = bytearray(hashlib.sha256(P).digest()) 76 | 77 | return coincurve.PrivateKey(P) 78 | 79 | def per_commit_secret(self, n: int) -> str: 80 | return self.raw_per_commit_secret(n).secret.hex() 81 | 82 | def raw_per_commit_point(self, n: int) -> coincurve.PublicKey: 83 | return coincurve.PublicKey.from_secret(self.raw_per_commit_secret(n).secret) 84 | 85 | def per_commit_point(self, n: int) -> str: 86 | return self.raw_per_commit_point(n).format().hex() 87 | 88 | 89 | def test_shachain() -> None: 90 | # BOLT #3: 91 | # ## Generation Tests 92 | # name: generate_from_seed 0 final node 93 | # seed: 0x0000000000000000000000000000000000000000000000000000000000000000 94 | # I: 281474976710655 95 | # output: 0x02a40c85b6f28da08dfdbe0926c53fab2de6d28c10301f8f7c4073d5e42e3148 96 | keyset = KeySet( 97 | "01", 98 | "01", 99 | "01", 100 | "01", 101 | "0000000000000000000000000000000000000000000000000000000000000000", 102 | ) 103 | assert ( 104 | keyset.per_commit_secret(0xFFFFFFFFFFFF - 281474976710655) 105 | == "02a40c85b6f28da08dfdbe0926c53fab2de6d28c10301f8f7c4073d5e42e3148" 106 | ) 107 | 108 | # BOLT #3: 109 | # name: generate_from_seed FF final node 110 | # seed: 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF 111 | # I: 281474976710655 112 | # output: 0x7cc854b54e3e0dcdb010d7a3fee464a9687be6e8db3be6854c475621e007a5dc 113 | keyset = KeySet( 114 | "01", 115 | "01", 116 | "01", 117 | "01", 118 | "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", 119 | ) 120 | assert ( 121 | keyset.per_commit_secret(0xFFFFFFFFFFFF - 281474976710655) 122 | == "7cc854b54e3e0dcdb010d7a3fee464a9687be6e8db3be6854c475621e007a5dc" 123 | ) 124 | 125 | # BOLT #3: 126 | # name: generate_from_seed FF alternate bits 1 127 | # seed: 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF 128 | # I: 0xaaaaaaaaaaa 129 | # output: 0x56f4008fb007ca9acf0e15b054d5c9fd12ee06cea347914ddbaed70d1c13a528 130 | keyset = KeySet( 131 | "01", 132 | "01", 133 | "01", 134 | "01", 135 | "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", 136 | ) 137 | assert ( 138 | keyset.per_commit_secret(0xFFFFFFFFFFFF - 0xAAAAAAAAAAA) 139 | == "56f4008fb007ca9acf0e15b054d5c9fd12ee06cea347914ddbaed70d1c13a528" 140 | ) 141 | 142 | # BOLT #3: 143 | # name: generate_from_seed FF alternate bits 2 144 | # seed: 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF 145 | # I: 0x555555555555 146 | # output: 0x9015daaeb06dba4ccc05b91b2f73bd54405f2be9f217fbacd3c5ac2e62327d31 147 | keyset = KeySet( 148 | "01", 149 | "01", 150 | "01", 151 | "01", 152 | "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", 153 | ) 154 | assert ( 155 | keyset.per_commit_secret(0xFFFFFFFFFFFF - 0x555555555555) 156 | == "9015daaeb06dba4ccc05b91b2f73bd54405f2be9f217fbacd3c5ac2e62327d31" 157 | ) 158 | 159 | # BOLT #3: 160 | # name: generate_from_seed 01 last nontrivial node 161 | # seed: 0x0101010101010101010101010101010101010101010101010101010101010101 162 | # I: 1 163 | # output: 0x915c75942a26bb3a433a8ce2cb0427c29ec6c1775cfc78328b57f6ba7bfeaa9c 164 | keyset = KeySet( 165 | "01", 166 | "01", 167 | "01", 168 | "01", 169 | "0101010101010101010101010101010101010101010101010101010101010101", 170 | ) 171 | assert ( 172 | keyset.per_commit_secret(0xFFFFFFFFFFFF - 1) 173 | == "915c75942a26bb3a433a8ce2cb0427c29ec6c1775cfc78328b57f6ba7bfeaa9c" 174 | ) 175 | -------------------------------------------------------------------------------- /lnprototest/namespace.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/python3 2 | import pyln.spec.bolt1 3 | import pyln.spec.bolt2 4 | import pyln.spec.bolt7 5 | from pyln.proto.message import MessageNamespace 6 | from .signature import SigType 7 | from typing import List 8 | 9 | 10 | def make_namespace(csv: List[str]) -> MessageNamespace: 11 | """Load a namespace, replacing signature type""" 12 | ns = MessageNamespace() 13 | # We replace the fundamental signature type with our custom type, 14 | # then we load in all the csv files so they use it. 15 | ns.fundamentaltypes["signature"] = SigType() 16 | ns.load_csv(csv) 17 | return ns 18 | 19 | 20 | def peer_message_namespace() -> MessageNamespace: 21 | """Namespace containing all the peer messages""" 22 | return make_namespace( 23 | pyln.spec.bolt1.csv + pyln.spec.bolt2.csv + pyln.spec.bolt7.csv 24 | ) 25 | 26 | 27 | def namespace() -> MessageNamespace: 28 | return event_namespace 29 | 30 | 31 | def assign_namespace(ns: MessageNamespace) -> None: 32 | global event_namespace 33 | event_namespace = ns 34 | 35 | 36 | # By default, include all peer message bolts 37 | event_namespace = peer_message_namespace() 38 | -------------------------------------------------------------------------------- /lnprototest/proposals.py: -------------------------------------------------------------------------------- 1 | dual_fund_csv = [ 2 | "msgtype,tx_add_input,66", 3 | "msgdata,tx_add_input,channel_id,channel_id,", 4 | "msgdata,tx_add_input,serial_id,u64,", 5 | "msgdata,tx_add_input,prevtx_len,u16,", 6 | "msgdata,tx_add_input,prevtx,byte,prevtx_len", 7 | "msgdata,tx_add_input,prevtx_vout,u32,", 8 | "msgdata,tx_add_input,sequence,u32,", 9 | "msgdata,tx_add_input,script_sig_len,u16,", 10 | "msgdata,tx_add_input,script_sig,byte,script_sig_len", 11 | "msgtype,tx_add_output,67", 12 | "msgdata,tx_add_output,channel_id,channel_id,", 13 | "msgdata,tx_add_output,serial_id,u64,", 14 | "msgdata,tx_add_output,sats,u64,", 15 | "msgdata,tx_add_output,scriptlen,u16,", 16 | "msgdata,tx_add_output,script,byte,scriptlen", 17 | "msgtype,tx_remove_input,68", 18 | "msgdata,tx_remove_input,channel_id,channel_id,", 19 | "msgdata,tx_remove_input,serial_id,u64,", 20 | "msgtype,tx_remove_output,69", 21 | "msgdata,tx_remove_output,channel_id,channel_id,", 22 | "msgdata,tx_remove_output,serial_id,u64,", 23 | "msgtype,tx_complete,70", 24 | "msgdata,tx_complete,channel_id,channel_id,", 25 | "msgtype,tx_signatures,71", 26 | "msgdata,tx_signatures,channel_id,channel_id,", 27 | "msgdata,tx_signatures,txid,sha256,", 28 | "msgdata,tx_signatures,num_witnesses,u16,", 29 | "msgdata,tx_signatures,witness_stack,witness_stack,num_witnesses", 30 | "subtype,witness_stack", 31 | "subtypedata,witness_stack,num_input_witness,u16,", 32 | "subtypedata,witness_stack,witness_element,witness_element,num_input_witness", 33 | "subtype,witness_element", 34 | "subtypedata,witness_element,len,u16,", 35 | "subtypedata,witness_element,witness,byte,len", 36 | "msgtype,open_channel2,64", 37 | "msgdata,open_channel2,chain_hash,chain_hash,", 38 | "msgdata,open_channel2,channel_id,channel_id,", 39 | "msgdata,open_channel2,funding_feerate_perkw,u32,", 40 | "msgdata,open_channel2,commitment_feerate_perkw,u32,", 41 | "msgdata,open_channel2,funding_satoshis,u64,", 42 | "msgdata,open_channel2,dust_limit_satoshis,u64,", 43 | "msgdata,open_channel2,max_htlc_value_in_flight_msat,u64,", 44 | "msgdata,open_channel2,htlc_minimum_msat,u64,", 45 | "msgdata,open_channel2,to_self_delay,u16,", 46 | "msgdata,open_channel2,max_accepted_htlcs,u16,", 47 | "msgdata,open_channel2,locktime,u32,", 48 | "msgdata,open_channel2,funding_pubkey,point,", 49 | "msgdata,open_channel2,revocation_basepoint,point,", 50 | "msgdata,open_channel2,payment_basepoint,point,", 51 | "msgdata,open_channel2,delayed_payment_basepoint,point,", 52 | "msgdata,open_channel2,htlc_basepoint,point,", 53 | "msgdata,open_channel2,first_per_commitment_point,point,", 54 | "msgdata,open_channel2,channel_flags,byte,", 55 | "msgdata,open_channel2,tlvs,opening_tlvs,", 56 | "tlvtype,opening_tlvs,option_upfront_shutdown_script,1", 57 | "tlvdata,opening_tlvs,option_upfront_shutdown_script,shutdown_len,u16,", 58 | "tlvdata,opening_tlvs,option_upfront_shutdown_script,shutdown_scriptpubkey,byte,shutdown_len", 59 | "msgtype,accept_channel2,65", 60 | "msgdata,accept_channel2,channel_id,channel_id,", 61 | "msgdata,accept_channel2,funding_satoshis,u64,", 62 | "msgdata,accept_channel2,dust_limit_satoshis,u64,", 63 | "msgdata,accept_channel2,max_htlc_value_in_flight_msat,u64,", 64 | "msgdata,accept_channel2,htlc_minimum_msat,u64,", 65 | "msgdata,accept_channel2,minimum_depth,u32,", 66 | "msgdata,accept_channel2,to_self_delay,u16,", 67 | "msgdata,accept_channel2,max_accepted_htlcs,u16,", 68 | "msgdata,accept_channel2,funding_pubkey,point,", 69 | "msgdata,accept_channel2,revocation_basepoint,point,", 70 | "msgdata,accept_channel2,payment_basepoint,point,", 71 | "msgdata,accept_channel2,delayed_payment_basepoint,point,", 72 | "msgdata,accept_channel2,htlc_basepoint,point,", 73 | "msgdata,accept_channel2,first_per_commitment_point,point,", 74 | "msgdata,accept_channel2,tlvs,accept_tlvs,", 75 | "tlvtype,accept_tlvs,option_upfront_shutdown_script,1", 76 | "tlvdata,accept_tlvs,option_upfront_shutdown_script,shutdown_len,u16,", 77 | "tlvdata,accept_tlvs,option_upfront_shutdown_script,shutdown_scriptpubkey,byte,shutdown_len", 78 | "msgtype,init_rbf,72", 79 | "msgdata,init_rbf,channel_id,channel_id,", 80 | "msgdata,init_rbf,funding_satoshis,u64,", 81 | "msgdata,init_rbf,locktime,u32,", 82 | "msgdata,init_rbf,funding_feerate_perkw,u32,", 83 | "msgtype,ack_rbf,73", 84 | "msgdata,ack_rbf,channel_id,channel_id,", 85 | "msgdata,ack_rbf,funding_satoshis,u64,", 86 | ] 87 | 88 | # This is https://github.com/lightningnetwork/lightning-rfc/pull/880 89 | channel_type_csv = [ 90 | "tlvtype,open_channel_tlvs,channel_type,1", 91 | "tlvdata,open_channel_tlvs,channel_type,type,byte,...", 92 | "tlvtype,accept_channel_tlvs,channel_type,1", 93 | "tlvdata,accept_channel_tlvs,channel_type,type,byte,...", 94 | ] 95 | -------------------------------------------------------------------------------- /lnprototest/runner.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/python3 2 | import logging 3 | import shutil 4 | import tempfile 5 | 6 | import coincurve 7 | import functools 8 | 9 | from .bitfield import bitfield 10 | from .errors import SpecFileError 11 | from .structure import Sequence 12 | from .event import Event, MustNotMsg, ExpectMsg 13 | from .utils import privkey_expand 14 | from .keyset import KeySet 15 | from abc import ABC, abstractmethod 16 | from typing import Dict, Optional, List, Union, Any, Callable 17 | 18 | 19 | class Conn(object): 20 | """Class for connections. Details filled in by the particular runner.""" 21 | 22 | def __init__(self, connprivkey: str): 23 | """Create a connection from a node with the given hex privkey: we use 24 | trivial values for private keys, so we simply left-pad with zeroes""" 25 | self.name = connprivkey 26 | self.connprivkey = privkey_expand(connprivkey) 27 | self.pubkey = coincurve.PublicKey.from_secret(self.connprivkey.secret) 28 | self.expected_error = False 29 | self.must_not_events: List[MustNotMsg] = [] 30 | 31 | def __str__(self) -> str: 32 | return self.name 33 | 34 | 35 | class Runner(ABC): 36 | """Abstract base class for runners. 37 | 38 | Most of the runner parameters can be extracted at runtime, but we do 39 | require that minimum_depth be 3, just for test simplicity. 40 | """ 41 | 42 | def __init__(self, config: Any): 43 | self.config = config 44 | self.directory = tempfile.mkdtemp(prefix="lnpt-cl-") 45 | # key == connprivkey, value == Conn 46 | self.conns: Dict[str, Conn] = {} 47 | self.last_conn: Optional[Conn] = None 48 | self.stash: Dict[str, Dict[str, Any]] = {} 49 | self.logger = logging.getLogger(__name__) 50 | if self.config.getoption("verbose"): 51 | self.logger.setLevel(logging.DEBUG) 52 | else: 53 | self.logger.setLevel(logging.INFO) 54 | 55 | def _is_dummy(self) -> bool: 56 | """The DummyRunner returns True here, as it can't do some things""" 57 | return False 58 | 59 | def find_conn(self, connprivkey: Optional[str]) -> Optional[Conn]: 60 | # Default is whatever we specified last. 61 | if connprivkey is None: 62 | return self.last_conn 63 | if connprivkey in self.conns: 64 | self.last_conn = self.conns[connprivkey] 65 | return self.last_conn 66 | return None 67 | 68 | def add_conn(self, conn: Conn) -> None: 69 | self.conns[conn.name] = conn 70 | self.last_conn = conn 71 | 72 | def disconnect(self, event: Event, conn: Conn) -> None: 73 | if conn is None: 74 | raise SpecFileError(event, "Unknown conn") 75 | del self.conns[conn.name] 76 | self.check_final_error(event, conn, conn.expected_error, conn.must_not_events) 77 | 78 | def check_error(self, event: Event, conn: Conn) -> Optional[str]: 79 | conn.expected_error = True 80 | return None 81 | 82 | def post_check(self, sequence: Sequence) -> None: 83 | # Make sure no connection had an error. 84 | for conn_name in list(self.conns.keys()): 85 | logging.debug( 86 | f"disconnection connection with key={conn_name} and value={self.conns[conn_name]}" 87 | ) 88 | self.disconnect(sequence, self.conns[conn_name]) 89 | 90 | def restart(self) -> None: 91 | self.conns = {} 92 | self.last_conn = None 93 | self.stash = {} 94 | 95 | # FIXME: Why can't we use SequenceUnion here? 96 | def run(self, events: Union[Sequence, List[Event], Event]) -> None: 97 | sequence = Sequence(events) 98 | self.start() 99 | while True: 100 | all_done = sequence.action(self) 101 | self.post_check(sequence) 102 | if all_done: 103 | self.stop() 104 | return 105 | self.restart() 106 | 107 | def add_stash(self, stashname: str, vals: Any) -> None: 108 | """Add a dict to the stash.""" 109 | self.stash[stashname] = vals 110 | 111 | def get_stash(self, event: Event, stashname: str, default: Any = None) -> Any: 112 | """Get an entry from the stash.""" 113 | if stashname not in self.stash: 114 | if default is not None: 115 | return default 116 | raise SpecFileError(event, "Unknown stash name {}".format(stashname)) 117 | return self.stash[stashname] 118 | 119 | def teardown(self): 120 | """The Teardown method is called at the end of the test, 121 | and it is used to clean up the root dir where the tests are run.""" 122 | shutil.rmtree(self.directory) 123 | 124 | def runner_features( 125 | self, 126 | additional_features: Optional[List[int]] = None, 127 | globals: bool = False, 128 | ) -> str: 129 | """ 130 | Provide the features required by the node. 131 | """ 132 | if additional_features is None: 133 | return "" 134 | else: 135 | return bitfield(*additional_features) 136 | 137 | @abstractmethod 138 | def is_running(self) -> bool: 139 | """Return a boolean value that tells whether the runner is running 140 | or not. 141 | Is leave up to the runner implementation to keep the runner state""" 142 | pass 143 | 144 | @abstractmethod 145 | def connect(self, event: Event, connprivkey: str) -> None: 146 | pass 147 | 148 | @abstractmethod 149 | def check_final_error( 150 | self, 151 | event: Event, 152 | conn: Conn, 153 | expected: bool, 154 | must_not_events: List[MustNotMsg], 155 | ) -> None: 156 | pass 157 | 158 | @abstractmethod 159 | def start(self) -> None: 160 | pass 161 | 162 | @abstractmethod 163 | def stop(self, print_logs: bool = False) -> None: 164 | """ 165 | Stop the runner, and print all the log that the ln 166 | implementation produced. 167 | 168 | Print the log is useful when we have a failure e we need 169 | to debug what happens during the tests. 170 | """ 171 | pass 172 | 173 | @abstractmethod 174 | def recv(self, event: Event, conn: Conn, outbuf: bytes) -> None: 175 | pass 176 | 177 | @abstractmethod 178 | def get_output_message(self, conn: Conn, event: ExpectMsg) -> Optional[bytes]: 179 | pass 180 | 181 | @abstractmethod 182 | def getblockheight(self) -> int: 183 | pass 184 | 185 | @abstractmethod 186 | def trim_blocks(self, newheight: int) -> None: 187 | pass 188 | 189 | @abstractmethod 190 | def add_blocks(self, event: Event, txs: List[str], n: int) -> None: 191 | pass 192 | 193 | @abstractmethod 194 | def expect_tx(self, event: Event, txid: str) -> None: 195 | pass 196 | 197 | @abstractmethod 198 | def invoice(self, event: Event, amount: int, preimage: str) -> None: 199 | pass 200 | 201 | @abstractmethod 202 | def accept_add_fund(self, event: Event) -> None: 203 | pass 204 | 205 | @abstractmethod 206 | def fundchannel( 207 | self, 208 | event: Event, 209 | conn: Conn, 210 | amount: int, 211 | feerate: int = 0, 212 | expect_fail: bool = False, 213 | ) -> None: 214 | pass 215 | 216 | @abstractmethod 217 | def init_rbf( 218 | self, 219 | event: Event, 220 | conn: Conn, 221 | channel_id: str, 222 | amount: int, 223 | utxo_txid: str, 224 | utxo_outnum: int, 225 | feerate: int, 226 | ) -> None: 227 | pass 228 | 229 | @abstractmethod 230 | def addhtlc(self, event: Event, conn: Conn, amount: int, preimage: str) -> None: 231 | pass 232 | 233 | @abstractmethod 234 | def get_keyset(self) -> KeySet: 235 | pass 236 | 237 | @abstractmethod 238 | def get_node_privkey(self) -> str: 239 | pass 240 | 241 | @abstractmethod 242 | def get_node_bitcoinkey(self) -> str: 243 | pass 244 | 245 | @abstractmethod 246 | def has_option(self, optname: str) -> Optional[str]: 247 | pass 248 | 249 | @abstractmethod 250 | def add_startup_flag(self, flag: str) -> None: 251 | pass 252 | 253 | @abstractmethod 254 | def close_channel(self, channel_id: str) -> None: 255 | """ 256 | Close the channel with the specified channel id. 257 | 258 | :param channel_id: the channel id as string value where the 259 | caller want to close; 260 | :return No value in case of success is expected, 261 | but an `RpcError` is expected in case of err. 262 | """ 263 | pass 264 | 265 | 266 | def remote_revocation_basepoint() -> Callable[[Runner, Event, str], str]: 267 | """Get the remote revocation basepoint""" 268 | 269 | def _remote_revocation_basepoint(runner: Runner, event: Event, field: str) -> str: 270 | return runner.get_keyset().revocation_basepoint() 271 | 272 | return _remote_revocation_basepoint 273 | 274 | 275 | def remote_payment_basepoint() -> Callable[[Runner, Event, str], str]: 276 | """Get the remote payment basepoint""" 277 | 278 | def _remote_payment_basepoint(runner: Runner, event: Event, field: str) -> str: 279 | return runner.get_keyset().payment_basepoint() 280 | 281 | return _remote_payment_basepoint 282 | 283 | 284 | def remote_delayed_payment_basepoint() -> Callable[[Runner, Event, str], str]: 285 | """Get the remote delayed_payment basepoint""" 286 | 287 | def _remote_delayed_payment_basepoint( 288 | runner: Runner, event: Event, field: str 289 | ) -> str: 290 | return runner.get_keyset().delayed_payment_basepoint() 291 | 292 | return _remote_delayed_payment_basepoint 293 | 294 | 295 | def remote_htlc_basepoint() -> Callable[[Runner, Event, str], str]: 296 | """Get the remote htlc basepoint""" 297 | 298 | def _remote_htlc_basepoint(runner: Runner, event: Event, field: str) -> str: 299 | return runner.get_keyset().htlc_basepoint() 300 | 301 | return _remote_htlc_basepoint 302 | 303 | 304 | def remote_funding_pubkey() -> Callable[[Runner, Event, str], str]: 305 | """Get the remote funding pubkey (FIXME: we assume there's only one!)""" 306 | 307 | def _remote_funding_pubkey(runner: Runner, event: Event, field: str) -> str: 308 | return ( 309 | coincurve.PublicKey.from_secret( 310 | privkey_expand(runner.get_node_bitcoinkey()).secret 311 | ) 312 | .format() 313 | .hex() 314 | ) 315 | 316 | return _remote_funding_pubkey 317 | 318 | 319 | def remote_funding_privkey() -> Callable[[Runner, Event, str], str]: 320 | """Get the remote funding privkey (FIXME: we assume there's only one!)""" 321 | 322 | def _remote_funding_privkey(runner: Runner, event: Event, field: str) -> str: 323 | return runner.get_node_bitcoinkey() 324 | 325 | return _remote_funding_privkey 326 | 327 | 328 | def remote_per_commitment_point(n: int) -> Callable[[Runner, Event, str], str]: 329 | """Get the n'th remote per-commitment point""" 330 | 331 | def _remote_per_commitment_point( 332 | n: int, runner: Runner, event: Event, field: str 333 | ) -> str: 334 | return runner.get_keyset().per_commit_point(n) 335 | 336 | return functools.partial(_remote_per_commitment_point, n) 337 | 338 | 339 | def remote_per_commitment_secret(n: int) -> Callable[[Runner, Event, str], str]: 340 | """Get the n'th remote per-commitment secret""" 341 | 342 | def _remote_per_commitment_secret(runner: Runner, event: Event, field: str) -> str: 343 | return runner.get_keyset().per_commit_secret(n) 344 | 345 | return _remote_per_commitment_secret 346 | -------------------------------------------------------------------------------- /lnprototest/signature.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/python3 2 | import coincurve 3 | from io import BufferedIOBase 4 | from pyln.proto.message import FieldType, split_field 5 | from .utils import check_hex, privkey_expand 6 | from typing import Union, Tuple, Dict, Any, Optional, cast 7 | 8 | 9 | class Sig(object): 10 | """The value of a signature, either as a privkey/hash pair or a raw 11 | signature. This has the property that if the raw signature is a valid 12 | signature of privkey over hash, they are considered "equal" 13 | """ 14 | 15 | def __init__(self, *args: Any): 16 | """Either a 64-byte hex/bytes value, or a PrivateKey and a hash""" 17 | if len(args) == 1: 18 | if type(args[0]) is bytes: 19 | if len(args[0]) != 64: 20 | raise ValueError("Sig() with 1 arg expects 64 bytes or 128 hexstr") 21 | self.sigval: Union[bytes, None] = cast(bytes, args[0]) 22 | else: 23 | if type(args[0]) is not str: 24 | raise TypeError("Expected hexsig or Privkey, hash") 25 | if len(args[0]) != 128: 26 | self.sigval = Sig.from_der(bytes.fromhex(args[0])) 27 | else: 28 | self.sigval = bytes.fromhex(args[0]) 29 | elif len(args) == 2: 30 | self.sigval = None 31 | self.privkey = privkey_expand(args[0]) 32 | self.hashval = bytes.fromhex(check_hex(args[1], 64)) 33 | else: 34 | raise TypeError("Expected hexsig or Privkey, hash") 35 | 36 | @staticmethod 37 | def to_der(b: bytes) -> bytes: 38 | """Seriously fuck off with DER encoding :(""" 39 | r = b[0:32] 40 | s = b[32:64] 41 | # Trim zero bytes 42 | while r[0] == 0: 43 | r = r[1:] 44 | # Prepend 0 again if would be negative 45 | if r[0] & 0x80: 46 | r = bytes([0]) + r 47 | # Trim zero bytes 48 | while s[0] == 0: 49 | s = s[1:] 50 | # Prepend 0 again if would be negative 51 | if s[0] & 0x80: 52 | s = bytes([0]) + s 53 | 54 | # 2 == integer, next == length 55 | ret = bytes([0x02, len(r)]) + r + bytes([0x02, len(s)]) + s 56 | # 30 == compound, next = length 57 | return bytes([0x30, len(ret)]) + ret 58 | 59 | @staticmethod 60 | def from_der(b: bytes) -> bytes: 61 | """Sigh. Seriously, WTF is it with DER encoding?""" 62 | if b[0] != 0x30 or b[1] != len(b) - 2 or b[2] != 0x02: 63 | raise ValueError("{} is not a DER signature?".format(b.hex())) 64 | rlen = b[3] 65 | r = b[4 : 4 + rlen].rjust(32, bytes(1))[-32:] 66 | assert len(r) == 32 67 | if b[4 + rlen] != 0x02: 68 | raise ValueError("{} is not a DER signature?".format(b.hex())) 69 | s = b[4 + rlen + 1 + 1 :].rjust(32, bytes(1))[-32:] 70 | assert len(s) == 32 71 | return r + s 72 | 73 | def __eq__(self, other: Any) -> bool: 74 | # For convenience of using stashed objects, we allow comparison with str 75 | if isinstance(other, str): 76 | othersig = Sig(other) 77 | else: 78 | othersig = cast(Sig, other) 79 | if self.sigval and othersig.sigval: 80 | return self.sigval == othersig.sigval 81 | elif not self.sigval and not othersig.sigval: 82 | return self.privkey == othersig.privkey and self.hashval == othersig.hashval 83 | elif not self.sigval: 84 | a = self 85 | b = othersig 86 | else: 87 | a = othersig 88 | b = self 89 | # A has a privkey/hash, B has a sigval. 90 | pubkey = coincurve.PublicKey.from_secret(a.privkey.secret) 91 | assert b.sigval is not None 92 | if coincurve.verify_signature( 93 | self.to_der(b.sigval), a.hashval, pubkey.format(), hasher=None 94 | ): 95 | return True 96 | return False 97 | 98 | def to_str(self) -> str: 99 | if self.sigval: 100 | return self.sigval.hex() 101 | else: 102 | return "Sig({},{})".format(self.privkey.secret.hex(), self.hashval.hex()) 103 | 104 | @staticmethod 105 | def from_str(s: str) -> Tuple["Sig", str]: 106 | a, b = split_field(s) 107 | if a.startswith("Sig("): 108 | privkey = a[4:] 109 | a, b = split_field(b[1:]) 110 | # Trim ) off Sig() 111 | return Sig(privkey, a[:-1]), b 112 | return Sig(bytes.fromhex(a)), b 113 | 114 | def to_bin(self) -> bytes: 115 | if not self.sigval: 116 | return self.from_der(self.privkey.sign(self.hashval, hasher=None)) 117 | else: 118 | return self.sigval 119 | 120 | 121 | class SigType(FieldType): 122 | """A signature type which has special comparison properties""" 123 | 124 | def __init__(self) -> None: 125 | super().__init__("signature") 126 | 127 | def val_to_str(self, v: Sig, otherfields: Dict[str, Any]) -> str: 128 | return v.to_str() 129 | 130 | def val_from_str(self, s: str) -> Tuple["Sig", str]: 131 | return Sig.from_str(s) 132 | 133 | def write( 134 | self, io_out: BufferedIOBase, v: Sig, otherfields: Dict[str, Any] 135 | ) -> None: 136 | io_out.write(v.to_bin()) 137 | 138 | def read(self, io_in: BufferedIOBase, otherfields: Dict[str, Any]) -> Optional[Sig]: 139 | val = io_in.read(64) 140 | if len(val) == 0: 141 | return None 142 | elif len(val) != 64: 143 | raise ValueError("{}: not enough remaining".format(self)) 144 | return Sig(val) 145 | 146 | 147 | def test_der() -> None: 148 | der = b"0E\x02!\x00\xa0\xb3\x7f\x8f\xbah<\xc6\x8fet\xcdC\xb3\x9f\x03C\xa5\x00\x08\xbfl\xce\xa9\xd121\xd9\xe7\xe2\xe1\xe4\x02 \x11\xed\xc8\xd3\x07%B\x96&J\xeb\xfc=\xc7l\xd8\xb6h7:\x07/\xd6Fe\xb5\x00\x00\xe9\xfc\xceR" 149 | sig = Sig.from_der(der) 150 | der2 = Sig.to_der(sig) 151 | assert der == der2 152 | 153 | 154 | def test_signature() -> None: 155 | s = Sig("01", "00" * 32) 156 | 157 | assert s == s 158 | b = s.to_bin() 159 | s2 = Sig(b) 160 | 161 | assert s == s2 162 | assert s2 == s 163 | -------------------------------------------------------------------------------- /lnprototest/stash/__init__.py: -------------------------------------------------------------------------------- 1 | """stash provides functions which resolve at action time. 2 | 3 | During a run, Events often put state in the runner object for later 4 | events to access at action time. This is called the "stash". 5 | 6 | These accessors actually return functions, which most Events know 7 | means they're to be called at action time. For the sake of (perhaps 8 | non-Pythony) test authors, they are always called as (), even if they 9 | simply return another function. 10 | 11 | """ 12 | 13 | from .stash import ( 14 | commitsig_to_send, 15 | commitsig_to_recv, 16 | channel_id, 17 | channel_announcement, 18 | channel_update, 19 | get_member, 20 | rcvd, 21 | sent, 22 | funding_amount, 23 | funding_pubkey, 24 | funding_tx, 25 | funding_txid, 26 | funding, 27 | funding_close_tx, 28 | htlc_sigs_to_send, 29 | htlc_sigs_to_recv, 30 | locking_script, 31 | witnesses, 32 | stash_field_from_event, 33 | ) 34 | -------------------------------------------------------------------------------- /lnprototest/stash/stash.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import time 3 | import coincurve 4 | 5 | from typing import Callable, Optional, Any 6 | from pyln.proto.message import Message 7 | 8 | from lnprototest import Runner, Event, Side, SpecFileError, Funding 9 | 10 | 11 | def commitsig_to_send() -> Callable[[Runner, Event, str], str]: 12 | """Get the appropriate signature for the local side to send to the remote""" 13 | 14 | def _commitsig_to_send(runner: Runner, event: Event, field: str) -> str: 15 | tx = runner.get_stash(event, "Commit").remote_unsigned_tx() 16 | return runner.get_stash(event, "Commit").local_sig(tx) 17 | 18 | return _commitsig_to_send 19 | 20 | 21 | def commitsig_to_recv() -> Callable[[Runner, Event, str], str]: 22 | """Get the appropriate signature for the remote side to send to the local""" 23 | 24 | def _commitsig_to_recv(runner: Runner, event: Event, field: str) -> str: 25 | tx = runner.get_stash(event, "Commit").local_unsigned_tx() 26 | return runner.get_stash(event, "Commit").remote_sig(tx) 27 | 28 | return _commitsig_to_recv 29 | 30 | 31 | def _htlc_sigs(signer: Side, runner: Runner, event: Event, field: str) -> str: 32 | sigs = runner.get_stash(event, "Commit").htlc_sigs(signer, not signer) 33 | return "[" + ",".join([sig.to_str() for sig in sigs]) + "]" 34 | 35 | 36 | def htlc_sigs_to_send() -> Callable[[Runner, Event, str], str]: 37 | """Get the HTLC signatures for local side to send to the remote""" 38 | return functools.partial(_htlc_sigs, Side.local) 39 | 40 | 41 | def htlc_sigs_to_recv() -> Callable[[Runner, Event, str], str]: 42 | """Get the HTLC signatures for remote side to send to the local""" 43 | return functools.partial(_htlc_sigs, Side.remote) 44 | 45 | 46 | def channel_id() -> Callable[[Runner, Event, str], str]: 47 | """Get the channel_id for the current Commit""" 48 | 49 | def _channel_id(runner: Runner, event: Event, field: str) -> str: 50 | return runner.get_stash(event, "Commit").funding.channel_id() 51 | 52 | return _channel_id 53 | 54 | 55 | def channel_id_v2() -> Callable[[Runner, Event, str], str]: 56 | """Get the channel_id for the current Commit for a v2 channel open""" 57 | 58 | def _channel_id(runner: Runner, event: Event, field: str) -> str: 59 | return runner.get_stash(event, "Commit").channel_id_v2() 60 | 61 | return _channel_id 62 | 63 | 64 | def channel_announcement( 65 | short_channel_id: str, features: bytes 66 | ) -> Callable[[Runner, Event, str], str]: 67 | """Get the channel_announcement for the current Commit""" 68 | 69 | def _channel_announcement( 70 | short_channel_id: str, features: bytes, runner: Runner, event: Event, field: str 71 | ) -> Message: 72 | return runner.get_stash(event, "Commit").channel_announcement( 73 | short_channel_id, features 74 | ) 75 | 76 | return functools.partial(_channel_announcement, short_channel_id, features) 77 | 78 | 79 | def channel_update( 80 | short_channel_id: str, 81 | side: Side, 82 | disable: bool, 83 | cltv_expiry_delta: int, 84 | htlc_minimum_msat: int, 85 | fee_base_msat: int, 86 | fee_proportional_millionths: int, 87 | htlc_maximum_msat: Optional[int], 88 | timestamp: Optional[int] = None, 89 | ) -> Callable[[Runner, Event, str], str]: 90 | """Get a channel_update for the current Commit""" 91 | 92 | def _channel_update( 93 | short_channel_id: str, 94 | side: Side, 95 | disable: bool, 96 | cltv_expiry_delta: int, 97 | htlc_minimum_msat: int, 98 | fee_base_msat: int, 99 | fee_proportional_millionths: int, 100 | timestamp: Optional[int], 101 | htlc_maximum_msat: Optional[int], 102 | runner: Runner, 103 | event: Event, 104 | field: str, 105 | ) -> Message: 106 | """Get the channel_update""" 107 | if timestamp is None: 108 | timestamp = int(time.time()) 109 | return runner.get_stash(event, "Commit").channel_update( 110 | short_channel_id, 111 | side, 112 | disable, 113 | cltv_expiry_delta, 114 | htlc_maximum_msat, 115 | fee_base_msat, 116 | fee_proportional_millionths, 117 | timestamp, 118 | htlc_maximum_msat, 119 | ) 120 | 121 | return functools.partial( 122 | _channel_update, 123 | short_channel_id, 124 | side, 125 | disable, 126 | cltv_expiry_delta, 127 | htlc_minimum_msat, 128 | fee_base_msat, 129 | fee_proportional_millionths, 130 | htlc_maximum_msat, 131 | timestamp, 132 | ) 133 | 134 | 135 | def get_member( 136 | event: Event, runner: "Runner", stashname: str, var: str, last: bool = True 137 | ) -> str: 138 | """Get member field from stash for ExpectMsg or Msg. 139 | 140 | If var contains a '.' then we look for that message to extract the field. If last is True, we get the last message, otherwise the first. 141 | """ 142 | stash = runner.get_stash(event, stashname) 143 | if "." in var: 144 | prevname, _, var = var.partition(".") 145 | else: 146 | prevname = "" 147 | if last: 148 | seq = reversed(stash) 149 | else: 150 | seq = stash 151 | 152 | for name, d in seq: 153 | if prevname == "" or name == prevname: 154 | if var not in d: 155 | raise SpecFileError( 156 | event, 157 | "{}: {} did not receive a {}".format(stashname, prevname, var), 158 | ) 159 | return d[var] 160 | raise SpecFileError(event, "{}: have no prior {}".format(stashname, prevname)) 161 | 162 | 163 | def _get_member( 164 | stashname: str, 165 | fieldname: Optional[str], 166 | casttype: Any, 167 | # This is the signature which Msg() expects for callable values: 168 | runner: "Runner", 169 | event: Event, 170 | field: str, 171 | ) -> Any: 172 | # If they don't specify fieldname, it's same as this field. 173 | if fieldname is None: 174 | fieldname = field 175 | strval = get_member(event, runner, stashname, fieldname) 176 | try: 177 | return casttype(strval) 178 | except ValueError: 179 | raise SpecFileError( 180 | event, 181 | "{}.{} is {}, not a valid {}".format( 182 | stashname, fieldname, strval, casttype 183 | ), 184 | ) 185 | 186 | 187 | def rcvd( 188 | fieldname: Optional[str] = None, casttype: Any = str 189 | ) -> Callable[[Runner, Event, Any], Any]: 190 | """Use previous ExpectMsg field (as string) 191 | 192 | fieldname can be [msg].[field] or just [field] for last ExpectMsg 193 | 194 | """ 195 | return functools.partial(_get_member, "ExpectMsg", fieldname, casttype) 196 | 197 | 198 | def sent( 199 | fieldname: Optional[str] = None, casttype: Any = str 200 | ) -> Callable[[Runner, Event, Any], Any]: 201 | """Use previous Msg field (as string) 202 | 203 | fieldname can be [msg].[field] or just [field] for last Msg 204 | 205 | """ 206 | return functools.partial(_get_member, "Msg", fieldname, casttype) 207 | 208 | 209 | def funding_amount() -> Callable[[Runner, Event, str], int]: 210 | """Get the stashed funding amount""" 211 | 212 | def _funding_amount(runner: Runner, event: Event, field: str) -> int: 213 | return runner.get_stash(event, "Funding").amount 214 | 215 | return _funding_amount 216 | 217 | 218 | def funding_pubkey(side: Side) -> Callable[[Runner, Event, str], str]: 219 | """Get the stashed funding pubkey for side""" 220 | 221 | def _funding_pubkey(side: Side, runner: Runner, event: Event, field: str) -> str: 222 | return coincurve.PublicKey.from_secret( 223 | runner.get_stash(event, "Funding").funding_privkeys[side].secret 224 | ) 225 | 226 | return functools.partial(_funding_pubkey, side) 227 | 228 | 229 | def funding_tx() -> Callable[[Runner, Event, str], str]: 230 | """Get the funding transaction (as stashed by CreateFunding)""" 231 | 232 | def _funding_tx(runner: Runner, event: Event, field: str) -> str: 233 | return runner.get_stash(event, "FundingTx") 234 | 235 | return _funding_tx 236 | 237 | 238 | def funding_txid() -> Callable[[Runner, Event, str], str]: 239 | """Get the stashed funding transaction id""" 240 | 241 | def _funding_txid(runner: Runner, event: Event, field: str) -> str: 242 | return runner.get_stash(event, "Funding").txid 243 | 244 | return _funding_txid 245 | 246 | 247 | def funding() -> Callable[[Runner, Event, str], Funding]: 248 | """Get the stashed Funding (as stashed by CreateFunding or AcceptFunding)""" 249 | 250 | def _funding(runner: Runner, event: Event, field: str) -> Funding: 251 | return runner.get_stash(event, "Funding") 252 | 253 | return _funding 254 | 255 | 256 | def witnesses() -> Callable[[Runner, Event, str], str]: 257 | """Get the witnesses for the stashed funding tx""" 258 | 259 | def _witnesses(runner: Runner, event: Event, field: str) -> str: 260 | funding = runner.get_stash(event, "Funding") 261 | return funding.our_witnesses() 262 | 263 | return _witnesses 264 | 265 | 266 | def locking_script() -> Callable[[Runner, Event, str], str]: 267 | def _locking_script(runner: Runner, event: Event, field: str) -> str: 268 | return runner.get_stash(event, "Funding").locking_script().hex() 269 | 270 | return _locking_script 271 | 272 | 273 | def funding_close_tx() -> Callable[[Runner, Event, str], str]: 274 | def _funding_close_tx(runner: Runner, event: Event, field: str) -> str: 275 | return runner.get_stash(event, "Funding").close_tx() 276 | 277 | return _funding_close_tx 278 | 279 | 280 | def stash_field_from_event( 281 | stash_key: str, field_name: Optional[str] = None, dummy_val: Optional[Any] = None 282 | ) -> Callable[[Runner, Event, str], str]: 283 | """Generic stash function to get the information back from a previous event""" 284 | 285 | def _stash_field_from_event(runner: Runner, event: Event, field: str) -> str: 286 | if runner._is_dummy(): 287 | return dummy_val 288 | field = field if field_name is None else field_name 289 | return runner.get_stash(event, stash_key).fields[field] 290 | 291 | return _stash_field_from_event 292 | -------------------------------------------------------------------------------- /lnprototest/structure.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/python3 2 | import io 3 | import logging 4 | 5 | from .event import Event, ExpectMsg, ResolvableBool 6 | from .errors import SpecFileError, EventError 7 | from .namespace import namespace 8 | from pyln.proto.message import Message 9 | from typing import Union, List, Optional, TYPE_CHECKING, cast 10 | 11 | if TYPE_CHECKING: 12 | # Otherwise a circular dependency 13 | from .runner import Runner, Conn 14 | 15 | # These can all be fed to a Sequence() initializer. 16 | SequenceUnion = Union["Sequence", List[Event], Event] 17 | 18 | 19 | class Sequence(Event): 20 | """A sequence of ordered events""" 21 | 22 | def __init__( 23 | self, 24 | events: Union["Sequence", List[Event], Event], 25 | enable: ResolvableBool = True, 26 | ): 27 | """Events can be a Sequence, a single Event, or a list of Events. If 28 | enable is False, this turns into a noop (e.g. if runner doesn't support 29 | it).""" 30 | super().__init__() 31 | self.enable = enable 32 | if type(events) is Sequence: 33 | # mypy gets upset because Sequence isn't defined yet. 34 | self.events = events.events # type: ignore 35 | self.enable = events.enable # type: ignore 36 | self.name = events.name # type: ignore 37 | elif isinstance(events, Event): 38 | self.events = [events] 39 | else: 40 | self.events = events 41 | 42 | def enabled(self, runner: "Runner") -> bool: 43 | return self.resolve_arg("enable", runner, self.enable) 44 | 45 | def action(self, runner: "Runner", skip_first: bool = False) -> bool: 46 | super().action(runner) 47 | all_done = True 48 | for e in self.events: 49 | logging.debug(f"receiving event {e}") 50 | if not e.enabled(runner): 51 | continue 52 | if skip_first: 53 | skip_first = False 54 | else: 55 | all_done &= e.action(runner) 56 | return all_done 57 | 58 | @staticmethod 59 | def ignored_by_all( 60 | msg: Message, sequences: List["Sequence"] 61 | ) -> Optional[List[Message]]: 62 | # If they all say the same thing, that's the answer. 63 | rets = [cast(ExpectMsg, s.events[0]).ignore(msg) for s in sequences] 64 | if all([ignored == rets[0] for ignored in rets[1:]]): 65 | return rets[0] 66 | return None 67 | 68 | @staticmethod 69 | def match_which_sequence( 70 | runner: "Runner", msg: Message, sequences: List["Sequence"] 71 | ) -> Optional["Sequence"]: 72 | """Return which sequence expects this msg, or None""" 73 | 74 | for s in sequences: 75 | failreason = cast(ExpectMsg, s.events[0]).message_match(runner, msg) 76 | if failreason is None: 77 | return s 78 | 79 | return None 80 | 81 | 82 | class OneOf(Event): 83 | """Event representing multiple possible sequences, one of which should happen""" 84 | 85 | def __init__(self, *args: SequenceUnion): 86 | super().__init__() 87 | self.sequences = [] 88 | for s in args: 89 | seq = Sequence(s) 90 | if len(seq.events) == 0: 91 | raise ValueError("{} is an empty sequence".format(s)) 92 | self.sequences.append(seq) 93 | 94 | def enabled_sequences(self, runner: "Runner") -> List[Sequence]: 95 | """Returns all enabled sequences""" 96 | return [s for s in self.sequences if s.enabled(runner)] 97 | 98 | def action(self, runner: "Runner") -> bool: 99 | super().action(runner) 100 | 101 | # Check they all use the same conn! 102 | conn: Optional[Conn] = None 103 | for s in self.sequences: 104 | c = cast(ExpectMsg, s.events[0]).find_conn(runner) 105 | if conn is None: 106 | conn = c 107 | elif c != conn: 108 | raise SpecFileError(self, "sequences do not all use the same conn?") 109 | assert conn 110 | 111 | while True: 112 | event = self.sequences[0].events[0] 113 | binmsg = runner.get_output_message(conn, event) 114 | if binmsg is None: 115 | raise EventError(self, f"Did not receive a message {event} from runner") 116 | 117 | try: 118 | msg = Message.read(namespace(), io.BytesIO(binmsg)) 119 | except ValueError as ve: 120 | raise EventError(self, "Invalid msg {}: {}".format(binmsg.hex(), ve)) 121 | 122 | ignored = Sequence.ignored_by_all(msg, self.enabled_sequences(runner)) 123 | # If they gave us responses, send those now. 124 | if ignored is not None: 125 | for msg in ignored: 126 | binm = io.BytesIO() 127 | msg.write(binm) 128 | runner.recv(self, conn, binm.getvalue()) 129 | continue 130 | 131 | seq = Sequence.match_which_sequence( 132 | runner, msg, self.enabled_sequences(runner) 133 | ) 134 | if seq is not None: 135 | # We found the sequence, run it 136 | return seq.action(runner, skip_first=True) 137 | 138 | raise EventError( 139 | self, 140 | "None of the sequences {} matched {}".format( 141 | self.enabled_sequences(runner), msg.to_str() 142 | ), 143 | ) 144 | 145 | 146 | class AnyOrder(Event): 147 | """Event representing multiple sequences, all of which should happen, but not defined which order they would happen""" 148 | 149 | def __init__(self, *args: SequenceUnion): 150 | super().__init__() 151 | self.sequences = [] 152 | for s in args: 153 | seq = Sequence(s) 154 | if len(seq.events) == 0: 155 | raise ValueError("{} is an empty sequence".format(s)) 156 | self.sequences.append(seq) 157 | 158 | def enabled_sequences(self, runner: "Runner") -> List[Sequence]: 159 | """Returns all enabled sequences""" 160 | return [s for s in self.sequences if s.enabled(runner)] 161 | 162 | def action(self, runner: "Runner") -> bool: 163 | super().action(runner) 164 | 165 | # Check they all use the same conn! 166 | conn = None 167 | for s in self.sequences: 168 | c = cast(ExpectMsg, s.events[0]).find_conn(runner) 169 | if conn is None: 170 | conn = c 171 | elif c != conn: 172 | raise SpecFileError(self, "sequences do not all use the same conn?") 173 | assert conn 174 | 175 | all_done = True 176 | sequences = self.enabled_sequences(runner) 177 | while sequences != []: 178 | # Get message 179 | binmsg = runner.get_output_message(conn, sequences[0].events[0]) 180 | if binmsg is None: 181 | raise EventError( 182 | self, 183 | "Did not receive a message from runner, still expecting {}".format( 184 | [s.events[0] for s in sequences] 185 | ), 186 | ) 187 | 188 | try: 189 | msg = Message.read(namespace(), io.BytesIO(binmsg)) 190 | except ValueError as ve: 191 | raise EventError(self, "Invalid msg {}: {}".format(binmsg.hex(), ve)) 192 | 193 | ignored = Sequence.ignored_by_all(msg, self.enabled_sequences(runner)) 194 | # If they gave us responses, send those now. 195 | if ignored is not None: 196 | for msg in ignored: 197 | binm = io.BytesIO() 198 | msg.write(binm) 199 | runner.recv(self, conn, binm.getvalue()) 200 | continue 201 | 202 | seq = Sequence.match_which_sequence(runner, msg, sequences) 203 | if seq is not None: 204 | sequences.remove(seq) 205 | all_done &= seq.action(runner, skip_first=True) 206 | continue 207 | 208 | raise EventError( 209 | self, 210 | "Message did not match any sequences {}: {}".format( 211 | [s.events[0] for s in sequences], msg.to_str() 212 | ), 213 | ) 214 | return all_done 215 | 216 | 217 | class TryAll(Event): 218 | """Event representing multiple sequences, each of which should be tested""" 219 | 220 | def __init__(self, *args: SequenceUnion): 221 | super().__init__() 222 | self.sequences = [Sequence(s) for s in args] 223 | self.done = [False] * len(self.sequences) 224 | 225 | def action(self, runner: "Runner") -> bool: 226 | super().action(runner) 227 | 228 | # Take first undone one, or if that fails, first enabled one. 229 | first_enabled = None 230 | first_undone = None 231 | all_done = True 232 | for i, s in enumerate(self.sequences): 233 | if not s.enabled(runner): 234 | continue 235 | if not first_enabled: 236 | first_enabled = s 237 | if self.done[i]: 238 | continue 239 | if not first_undone: 240 | first_undone = s 241 | self.done[i] = True 242 | else: 243 | all_done = False 244 | 245 | # Note: they might *all* be disabled! 246 | if first_undone: 247 | first_undone.action(runner) 248 | elif first_enabled: 249 | first_enabled.action(runner) 250 | 251 | return all_done 252 | 253 | 254 | def test_empty_sequence() -> None: 255 | class nullrunner(object): 256 | class dummyconfig(object): 257 | def getoption(self, name: str) -> bool: 258 | return False 259 | 260 | def __init__(self) -> None: 261 | self.config = self.dummyconfig() 262 | 263 | # This sequence should be tried twice. 264 | seq = Sequence(TryAll([], [])) 265 | assert seq.action(nullrunner()) is False # type: ignore 266 | assert seq.action(nullrunner()) is True # type: ignore 267 | -------------------------------------------------------------------------------- /lnprototest/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .ln_spec_utils import ( 2 | LightningUtils, 3 | connect_to_node_helper, 4 | open_and_announce_channel_helper, 5 | ) 6 | from .utils import ( 7 | Side, 8 | privkey_expand, 9 | wait_for, 10 | check_hex, 11 | gen_random_keyset, 12 | run_runner, 13 | pubkey_of, 14 | check_hex, 15 | privkey_for_index, 16 | merge_events_sequences, 17 | ) 18 | from .bitcoin_utils import ( 19 | ScriptType, 20 | BitcoinUtils, 21 | utxo, 22 | utxo_amount, 23 | funding_amount_for_utxo, 24 | tx_spendable, 25 | tx_out_for_index, 26 | ) 27 | -------------------------------------------------------------------------------- /lnprototest/utils/bitcoin_utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Bitcoin utils is a collection of methods that helps to 3 | work with bitcoin primitive. 4 | """ 5 | 6 | import hashlib 7 | 8 | from enum import Enum 9 | from typing import Tuple 10 | 11 | import bitcoin.core 12 | 13 | from bitcoin.core import Hash160, x 14 | from bitcoin.core.script import OP_0, OP_CHECKSIG, CScript 15 | from bitcoin.wallet import CBitcoinSecret 16 | 17 | 18 | class ScriptType(Enum): 19 | """ 20 | Type of Script used in the Runner. 21 | 22 | In particular, during the testing we need to have 23 | two type of script, the valid one and the invalid one. 24 | This is useful when is needed to send an invalid script. 25 | 26 | FIXME: naming is too simple. 27 | """ 28 | 29 | VALID_CLOSE_SCRIPT = 1 30 | INVALID_CLOSE_SCRIPT = 2 31 | 32 | 33 | class BitcoinUtils: 34 | """Main implementation class of the lightning networks utils. 35 | 36 | The implementation class contains only static methods that 37 | apply the rules specified by the BIP.""" 38 | 39 | @staticmethod 40 | def blockchain_hash() -> str: 41 | """Return the chain transaction hash. 42 | That in this case is the regtest transaction hash.""" 43 | return "06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f" 44 | 45 | @staticmethod 46 | def build_valid_script( 47 | script_type: ScriptType = ScriptType.VALID_CLOSE_SCRIPT, 48 | word: str = "lnprototest", 49 | ) -> str: 50 | """Build a valid bitcoin script and hide the primitive of the library""" 51 | secret_str = f"correct horse battery staple {word}" 52 | h = hashlib.sha256(secret_str.encode("ascii")).digest() 53 | seckey = CBitcoinSecret.from_secret_bytes(h) 54 | if script_type is ScriptType.VALID_CLOSE_SCRIPT: 55 | return CScript([OP_0, Hash160(seckey.pub)]).hex() 56 | elif script_type is ScriptType.INVALID_CLOSE_SCRIPT: 57 | return CScript([seckey.pub, OP_CHECKSIG]).hex() 58 | 59 | @staticmethod 60 | def build_script(hex: str) -> CScript: 61 | return CScript(x(hex)) 62 | 63 | 64 | # Here are the keys to spend funds, derived from BIP32 seed 65 | # `0000000000000000000000000000000000000000000000000000000000000001`: 66 | # 67 | # pubkey 0/0/1: 02d6a3c2d0cf7904ab6af54d7c959435a452b24a63194e1c4e7c337d3ebbb3017b 68 | # privkey 0/0/1: 76edf0c303b9e692da9cb491abedef46ca5b81d32f102eb4648461b239cb0f99 69 | # WIF 0/0/1: cRZtHFwyrV3CS1Muc9k4sXQRDhqA1Usgi8r7NhdEXLgM5CUEZufg 70 | # P2WPKH 0/0/1: bcrt1qsdzqt93xsyewdjvagndw9523m27e52er5ca7hm 71 | # UTXO: d3fb780146954eb42e371c80cbee1725f8ae330848522f105bda24e1fb1fc010/1 (0.01BTC) 72 | # 73 | # pubkey 0/0/2: 038f1573b4238a986470d250ce87c7a91257b6ba3baf2a0b14380c4e1e532c209d 74 | # privkey 0/0/2: bc2f48a76a6b8815940accaf01981d3b6347a68fbe844f81c50ecbadf27cd179 75 | # WIF 0/0/2: cTtWRYC39drNzaANPzDrgoYsMgs5LkfE5USKH9Kr9ySpEEdjYt3E 76 | # P2WPKH 0/0/2: bcrt1qlkt93775wmf33uacykc49v2j4tayn0yj25msjn 77 | # UTXO: d3fb780146954eb42e371c80cbee1725f8ae330848522f105bda24e1fb1fc010/0 (0.02BTC) 78 | # 79 | # pubkey 0/0/3: 02ffef0c295cf7ca3a4ceb8208534e61edf44c606e7990287f389f1ea055a1231c 80 | # privkey 0/0/3: 16c5027616e940d1e72b4c172557b3b799a93c0582f924441174ea556aadd01c 81 | # WIF 0/0/3: cNLxnoJSQDRzXnGPr4ihhy2oQqRBTjdUAM23fHLHbZ2pBsNbqMwb 82 | # P2WPKH 0/0/3: bcrt1q2ng546gs0ylfxrvwx0fauzcvhuz655en4kwe2c 83 | # UTXO: d3fb780146954eb42e371c80cbee1725f8ae330848522f105bda24e1fb1fc010/3 (0.03BTC) 84 | # 85 | # pubkey 0/0/4: 026957e53b46df017bd6460681d068e1d23a7b027de398272d0b15f59b78d060a9 86 | # privkey 0/0/4: 53ac43309b75d9b86bef32c5bbc99c500910b64f9ae089667c870c2cc69e17a4 87 | # WIF 0/0/4: cQPMJRjxse9i1jDeCo8H3khUMHYfXYomKbwF5zUqdPrFT6AmtTbd 88 | # P2WPKH 0/0/4: bcrt1qrdpwrlrmrnvn535l5eldt64lxm8r2nwkv0ruxq 89 | # UTXO: d3fb780146954eb42e371c80cbee1725f8ae330848522f105bda24e1fb1fc010/4 (0.04BTC) 90 | # 91 | # pubkey 0/0/5: 03a9f795ff2e4c27091f40e8f8277301824d1c3dfa6b0204aa92347314e41b1033 92 | # privkey 0/0/5: 16be98a5d4156f6f3af99205e9bc1395397bca53db967e50427583c94271d27f 93 | # WIF 0/0/5: cNLuxyjvR6ga2q6fdmSKxAd1CPQDShKV9yoA7zFKT7GJwZXr9MmT 94 | # P2WPKH 0/0/5: bcrt1q622lwmdzxxterumd746eu3d3t40pq53p62zhlz 95 | # UTXO: d3fb780146954eb42e371c80cbee1725f8ae330848522f105bda24e1fb1fc010/2 (48.89994700BTC) 96 | # 97 | # 98 | # We add another UTXO which is solely spendable by the test framework, and not accessible to the 99 | # runner -- needed for dual-funded tests. 100 | # 101 | # pubkey: 02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5 102 | # privkey: 0000000000000000000000000000000000000000000000000000000000000002 103 | # P2WPKH : bcrt1qq6hag67dl53wl99vzg42z8eyzfz2xlkvwk6f7m 104 | # UTXO: d3fb780146954eb42e371c80cbee1725f8ae330848522f105bda24e1fb1fc010/5 (0.005BTC) 105 | # 106 | # UTXO with a 1-of-5 multisig (results in a long/expensive witness) 107 | # -- needed for dual-funded tests. 108 | # 109 | # pubkey_1: 0253cdf835e328346a4f19de099cf3d42d4a7041e073cd4057a1c4fd7cdbb1228f 110 | # privkey_1: cPToYwmZxXaAaAw6XHi8aEVefYAoYxPs8TCFpsAF6JVWbg1NSaZE 111 | # pubkey_2: 03ae903722f21f85e651b8f9b18fc854084fb90eeb76452bdcfd0cb43a16a382a2 112 | # privkey_2: cMsCoM96my8y5DDHLJfpGzFo7EhnhNFscbARtNS8mz7vTV5PiVR3 113 | # pubkey_3: 036c264d68a9727afdc75949f7d7fa71910ae9ae8001a1fbffa6f7ce000976597c 114 | # privkey_3: cQE1RbQyGcecfzTurwayy9vJtapRmM9isUG6AVTxYf2seiBHcNMp 115 | # pubkey_4: 036429fa8a4ef0b2b1d5cb553e34eeb90a32ab19fae1f0024f332ab4f74283a728 116 | # privkey_4: cVKnBoJ294xQyzRWFkxSmxMr1PFdHxGgTVBrMWpSF6o8KoJhWbNY 117 | # pubkey_5: 03d4232f19ea85051e7b76bf5f01d03e17eea8751463dee36d71413a739de1a927 118 | # privkey_5: cRboxysVFZUxZQ3DdvoTRuBKfStQ46MAA5HTVgVQW6VKF88HrC4H 119 | # script: 51210253cdf835e328346a4f19de099cf3d42d4a7041e073cd4057a1c4fd7cdbb1228f2103ae903722f21f85e651b8f9b18fc854084fb90eeb76452bdcfd0cb43a16a382a221036c264d68a9727afdc75949f7d7fa71910ae9ae8001a1fbffa6f7ce000976597c21036429fa8a4ef0b2b1d5cb553e34eeb90a32ab19fae1f0024f332ab4f74283a7282103d4232f19ea85051e7b76bf5f01d03e17eea8751463dee36d71413a739de1a92755ae 120 | # P2WSH: bcrt1qug62lyrfd7khs7welgu28y66zzuq5nc4t9gdnyx3rjm9fud2f7gqm0ksxn 121 | # UTXO: d3fb780146954eb42e371c80cbee1725f8ae330848522f105bda24e1fb1fc010/6 (0.06BTC) 122 | # 123 | tx_spendable = "0200000000010184591a56720aabc8023cecf71801c5e0f9d049d0c550ab42412ad12a67d89f3a0000000000feffffff0780841e0000000000160014fd9658fbd476d318f3b825b152b152aafa49bc9240420f000000000016001483440596268132e6c99d44dae2d151dabd9a2b232c180a2901000000160014d295f76da2319791f36df5759e45b15d5e105221c0c62d000000000016001454d14ae910793e930d8e33d3de0b0cbf05aa533300093d00000000001600141b42e1fc7b1cd93a469fa67ed5eabf36ce354dd620a107000000000016001406afd46bcdfd22ef94ac122aa11f241244a37ecc808d5b000000000022002000b068df6e0e0542e776cea5ebe8f5f1a9b40b531ddd8e94b1a7ff9829b5bbaa024730440220367b9bfed0565bad2137124f736373626fa3135e59b20a7b5c1d8f2b8f1b26bb02202f664de39787082a376d222487f02ef19e45696c041044a6d579eecabb68e94501210356609a904a7026c7391d3fbf71ad92a00e04b4cd2fb6a8d1e69cbc0998f6690a65000000" 124 | 125 | 126 | def utxo(index: int = 0) -> Tuple[str, int, int, str, int]: 127 | """Helper to get a P2WPKH UTXO, amount, privkey and fee from the tx_spendable transaction""" 128 | 129 | amount = (index + 1) * 1000000 130 | if index == 0: 131 | txout = 1 132 | key = "76edf0c303b9e692da9cb491abedef46ca5b81d32f102eb4648461b239cb0f99" 133 | elif index == 1: 134 | txout = 0 135 | key = "bc2f48a76a6b8815940accaf01981d3b6347a68fbe844f81c50ecbadf27cd179" 136 | elif index == 2: 137 | txout = 3 138 | key = "16c5027616e940d1e72b4c172557b3b799a93c0582f924441174ea556aadd01c" 139 | elif index == 3: 140 | txout = 4 141 | key = "53ac43309b75d9b86bef32c5bbc99c500910b64f9ae089667c870c2cc69e17a4" 142 | elif index == 4: 143 | txout = 2 144 | key = "16be98a5d4156f6f3af99205e9bc1395397bca53db967e50427583c94271d27f" 145 | amount = 4983494700 146 | elif index == 5: 147 | txout = 5 148 | key = "0000000000000000000000000000000000000000000000000000000000000002" 149 | amount = 500000 150 | elif index == 6: 151 | txout = 6 152 | key = "38204720bc4f9647fd58c6d0a4bd3a6dd2be16d8e4273c4d1bdd5774e8c51eaf" 153 | amount = 6000000 154 | else: 155 | raise ValueError("index must be 0-6 inclusive") 156 | 157 | # Reasonable funding fee in sats 158 | reasonable_funding_fee = 200 159 | 160 | return txid_raw(tx_spendable), txout, amount, key, reasonable_funding_fee 161 | 162 | 163 | def tx_out_for_index(index: int = 0) -> int: 164 | _, txout, _, _, _ = utxo(index) 165 | return txout 166 | 167 | 168 | def utxo_amount(index: int = 0) -> int: 169 | """How much is this utxo worth""" 170 | _, _, amt, _, _ = utxo(index) 171 | return amt 172 | 173 | 174 | def funding_amount_for_utxo(index: int = 0) -> int: 175 | """How much can we fund a channel for using utxo #index?""" 176 | _, _, amt, _, fee = utxo(index) 177 | return amt - fee 178 | 179 | 180 | def txid_raw(tx: str) -> str: 181 | """Helper to get the txid of a tx: note this is in wire protocol order, not bitcoin order!""" 182 | return bitcoin.core.CTransaction.deserialize(bytes.fromhex(tx)).GetTxid().hex() 183 | -------------------------------------------------------------------------------- /lnprototest/utils/ln_spec_utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Lightning network Speck utils, is a collection of methods that helps to 3 | work with some concept of lightning network RFC 4 | 5 | It also contains a method to generate the correct sequence of channel opening 6 | and, and it feels a dictionary with all the propriety that needs to 7 | be used after this sequence of steps. 8 | 9 | author: Vincenzo PAlazzo https://github.com/vincenzopalazzo 10 | """ 11 | 12 | from typing import List, Optional 13 | 14 | 15 | class LightningUtils: 16 | """ 17 | Main implementation class of the lightning networks utils. 18 | 19 | The implementation class contains only static methods that 20 | apply the rules specified in the lightning network RFC. 21 | """ 22 | 23 | @staticmethod 24 | def derive_short_channel_id(block_height: int, tx_idx: int, tx_output) -> str: 25 | """ 26 | Derive the short channel id with the specified 27 | parameters, and return the result as string. 28 | 29 | RFC definition: https://github.com/lightning/bolts/blob/93909f67f6a48ee3f155a6224c182e612dd5f187/07-routing-gossip.md#definition-of-short_channel_id 30 | 31 | The short_channel_id is the unique description of the funding transaction. It is constructed as follows: 32 | - the most significant 3 bytes: indicating the block height 33 | - the next 3 bytes: indicating the transaction index within the block 34 | - the least significant 2 bytes: indicating the output index that pays to the channel. 35 | 36 | e.g: a short_channel_id might be written as 539268x845x1, indicating a channel on the 37 | output 1 of the transaction at index 845 of the block at height 539268. 38 | 39 | block_height: str 40 | Block height. 41 | tx_idx: int 42 | Transaction index inside the block. 43 | tx_output: int 44 | Output index inside the transaction. 45 | """ 46 | return f"{block_height}x{tx_idx}x{tx_output}" 47 | 48 | 49 | def connect_to_node_helper( 50 | runner: "Runner", 51 | tx_spendable: str, 52 | conn_privkey: str = "02", 53 | global_features: Optional[List[int]] = None, 54 | features: Optional[List[int]] = None, 55 | ) -> List["Event"]: 56 | """Helper function to make a connection with the node""" 57 | from lnprototest.utils.bitcoin_utils import tx_spendable 58 | from lnprototest import ( 59 | Connect, 60 | Block, 61 | ExpectMsg, 62 | Msg, 63 | ) 64 | 65 | return [ 66 | Block(blockheight=102, txs=[tx_spendable]), 67 | Connect(connprivkey=conn_privkey), 68 | ExpectMsg("init"), 69 | Msg( 70 | "init", 71 | globalfeatures=( 72 | runner.runner_features(globals=True) 73 | if global_features is None 74 | else ( 75 | runner.runner_features( 76 | global_features, 77 | globals=True, 78 | ) 79 | ) 80 | ), 81 | features=( 82 | runner.runner_features() 83 | if features is None 84 | else (runner.runner_features(features)) 85 | ), 86 | ), 87 | ] 88 | 89 | 90 | def open_and_announce_channel_helper( 91 | runner: "Runner", conn_privkey: str = "02", opts: dict = {} 92 | ) -> List["Event"]: 93 | from lnprototest.utils import gen_random_keyset, pubkey_of 94 | from lnprototest.utils.bitcoin_utils import ( 95 | BitcoinUtils, 96 | utxo, 97 | funding_amount_for_utxo, 98 | ) 99 | from lnprototest.utils.ln_spec_utils import LightningUtils 100 | from lnprototest import ( 101 | Block, 102 | ExpectMsg, 103 | Msg, 104 | Commit, 105 | Side, 106 | CreateFunding, 107 | remote_funding_pubkey, 108 | remote_revocation_basepoint, 109 | remote_payment_basepoint, 110 | remote_delayed_payment_basepoint, 111 | remote_htlc_basepoint, 112 | remote_per_commitment_point, 113 | remote_funding_privkey, 114 | msat, 115 | ) 116 | from lnprototest.stash import ( 117 | rcvd, 118 | funding, 119 | sent, 120 | commitsig_to_recv, 121 | channel_id, 122 | commitsig_to_send, 123 | funding_txid, 124 | funding_tx, 125 | stash_field_from_event, 126 | ) 127 | 128 | # Make up a channel between nodes 02 and 03, using bitcoin privkeys 10 and 20 129 | local_keyset = gen_random_keyset() 130 | local_funding_privkey = "20" 131 | if "block_height" in opts: 132 | block_height = opts["block_height"] 133 | else: 134 | block_height = 103 135 | 136 | short_channel_id = LightningUtils.derive_short_channel_id(block_height, 1, 0) 137 | opts["short_channel_id"] = short_channel_id 138 | opts["block_height"] = block_height + 6 139 | return [ 140 | Msg( 141 | "open_channel", 142 | chain_hash=BitcoinUtils.blockchain_hash(), 143 | temporary_channel_id="00" * 32, 144 | funding_satoshis=funding_amount_for_utxo(0), 145 | push_msat=0, 146 | dust_limit_satoshis=546, 147 | max_htlc_value_in_flight_msat=4294967295, 148 | channel_reserve_satoshis=9998, 149 | htlc_minimum_msat=0, 150 | feerate_per_kw=253, 151 | # clightning uses to_self_delay=6; we use 5 to test differentiation 152 | to_self_delay=5, 153 | max_accepted_htlcs=483, 154 | funding_pubkey=pubkey_of(local_funding_privkey), 155 | revocation_basepoint=local_keyset.revocation_basepoint(), 156 | payment_basepoint=local_keyset.payment_basepoint(), 157 | delayed_payment_basepoint=local_keyset.delayed_payment_basepoint(), 158 | htlc_basepoint=local_keyset.htlc_basepoint(), 159 | first_per_commitment_point=local_keyset.per_commit_point(0), 160 | channel_flags=1, 161 | ), 162 | ExpectMsg( 163 | "accept_channel", 164 | funding_pubkey=remote_funding_pubkey(), 165 | revocation_basepoint=remote_revocation_basepoint(), 166 | payment_basepoint=remote_payment_basepoint(), 167 | delayed_payment_basepoint=remote_delayed_payment_basepoint(), 168 | htlc_basepoint=remote_htlc_basepoint(), 169 | first_per_commitment_point=remote_per_commitment_point(0), 170 | minimum_depth=stash_field_from_event("accept_channel", dummy_val=3), 171 | channel_reserve_satoshis=9998, 172 | ), 173 | # Create and stash Funding object and FundingTx 174 | CreateFunding( 175 | *utxo(0), 176 | local_node_privkey="02", 177 | local_funding_privkey=local_funding_privkey, 178 | remote_node_privkey=runner.get_node_privkey(), 179 | remote_funding_privkey=remote_funding_privkey(), 180 | ), 181 | Commit( 182 | funding=funding(), 183 | opener=Side.local, 184 | local_keyset=local_keyset, 185 | local_to_self_delay=rcvd("to_self_delay", int), 186 | remote_to_self_delay=sent("to_self_delay", int), 187 | local_amount=msat(sent("funding_satoshis", int)), 188 | remote_amount=0, 189 | local_dust_limit=546, 190 | remote_dust_limit=546, 191 | feerate=253, 192 | local_features=sent("init.features"), 193 | remote_features=rcvd("init.features"), 194 | ), 195 | Msg( 196 | "funding_created", 197 | temporary_channel_id=rcvd(), 198 | funding_txid=funding_txid(), 199 | funding_output_index=0, 200 | signature=commitsig_to_send(), 201 | ), 202 | ExpectMsg( 203 | "funding_signed", channel_id=channel_id(), signature=commitsig_to_recv() 204 | ), 205 | # Mine it and get it deep enough to confirm channel. 206 | Block( 207 | blockheight=block_height, 208 | number=stash_field_from_event( 209 | "accept_channel", field_name="minimum_depth", dummy_val=3 210 | ), 211 | txs=[funding_tx()], 212 | ), 213 | ExpectMsg( 214 | "channel_ready", 215 | channel_id=channel_id(), 216 | second_per_commitment_point=remote_per_commitment_point(1), 217 | ), 218 | Msg( 219 | "channel_ready", 220 | channel_id=channel_id(), 221 | second_per_commitment_point=local_keyset.per_commit_point(1), 222 | ), 223 | # wait confirmations 224 | # 225 | # FIXME: Uh! do you know why this sucks, because lnprototest is lazy evaluated. 226 | # This is huggly, and we should change it at some point but this now works. 227 | Block( 228 | blockheight=lambda runner, event, _: block_height 229 | + runner.get_stash(event, "accept_channel").fields["minimum_depth"], 230 | number=6, 231 | ), 232 | ] 233 | -------------------------------------------------------------------------------- /lnprototest/utils/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utils module that implement common function used across lnprototest library. 3 | """ 4 | 5 | import string 6 | import coincurve 7 | import time 8 | import typing 9 | import logging 10 | import traceback 11 | 12 | from typing import Union, Sequence, List 13 | from enum import IntEnum 14 | 15 | from lnprototest.keyset import KeySet 16 | 17 | 18 | class Side(IntEnum): 19 | local = 0 20 | remote = 1 21 | 22 | def __not__(self) -> "Side": 23 | if self == Side.local: 24 | return Side.remote 25 | return Side.local 26 | 27 | 28 | def check_hex(val: str, digits: int) -> str: 29 | if not all(c in string.hexdigits for c in val): 30 | raise ValueError("{} is not valid hex".format(val)) 31 | if len(val) != digits: 32 | raise ValueError("{} not {} characters long".format(val, digits)) 33 | return val 34 | 35 | 36 | def privkey_expand(secret: str) -> coincurve.PrivateKey: 37 | # Privkey can be truncated, since we use tiny values a lot. 38 | return coincurve.PrivateKey(bytes.fromhex(secret).rjust(32, bytes(1))) 39 | 40 | 41 | def pubkey_of(privkey: str) -> str: 42 | """Return the public key corresponding to this privkey""" 43 | return ( 44 | coincurve.PublicKey.from_secret(privkey_expand(privkey).secret).format().hex() 45 | ) 46 | 47 | 48 | def privkey_for_index(index: int = 0) -> str: 49 | from lnprototest.utils.bitcoin_utils import utxo 50 | 51 | _, _, _, privkey, _ = utxo(index) 52 | return privkey 53 | 54 | 55 | def gen_random_keyset(counter: int = 20) -> KeySet: 56 | """Helper function to generate a random keyset.""" 57 | 58 | from lnprototest import privkey_expand 59 | 60 | return KeySet( 61 | revocation_base_secret=f"{counter + 1}", 62 | payment_base_secret=f"{counter + 2}", 63 | htlc_base_secret=f"{counter + 3}", 64 | delayed_payment_base_secret=f"{counter + 4}", 65 | shachain_seed="00" * 32, 66 | ) 67 | 68 | 69 | def wait_for(success: typing.Callable, timeout: int = 180) -> None: 70 | start_time = time.time() 71 | interval = 0.25 72 | while not success(): 73 | time_left = start_time + timeout - time.time() 74 | if time_left <= 0: 75 | raise ValueError("Timeout while waiting for {}", success) 76 | time.sleep(min(interval, time_left)) 77 | interval *= 2 78 | if interval > 5: 79 | interval = 5 80 | 81 | 82 | def get_traceback(e: Exception) -> str: 83 | lines = traceback.format_exception(type(e), e, e.__traceback__) 84 | return "".join(lines) 85 | 86 | 87 | def run_runner(runner: "Runner", test: Union[Sequence, List["Event"], "Event"]) -> None: 88 | """ 89 | The pytest using the assertion as safe failure, and the exception it is only 90 | an event that must not happen. 91 | 92 | From design, lnprototest fails with an exception, and for this reason, if the 93 | lnprototest throws an exception, we catch it, and we fail with an assent. 94 | """ 95 | try: 96 | runner.run(test) 97 | except Exception as ex: 98 | runner.stop(print_logs=True) 99 | logging.error(get_traceback(ex)) 100 | assert False, ex 101 | 102 | 103 | def merge_events_sequences( 104 | pre: Union[Sequence, List["Event"], "Event"], 105 | post: Union[Sequence, List["Event"], "Event"], 106 | ) -> Union[Sequence, List["Event"], "Event"]: 107 | """Merge the two list in the pre-post order""" 108 | pre.extend(post) 109 | return pre 110 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "lnprototest" 3 | version = "0.0.7" 4 | description = "Spec protocol tests for lightning network implementations" 5 | authors = ["Rusty Russell ", "Vincenzo Palazzo "] 6 | license = "MIT" 7 | readme = "README.md" 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.9" 11 | pyln-bolt4 = "^1.0.222" 12 | pyln-bolt2 = "^1.0.222" 13 | pyln-bolt1 = "^1.0.222" 14 | pyln-client = "^25.2.1" 15 | pyln-testing = "^25.2.1" 16 | crc32c = "^2.2.post0" 17 | # We accidentally published version 1.0.186 instead of 1.0.2.186. That 18 | # version is now yanked by caches remain, so this is a temporary fix. 19 | pyln-bolt7 = "^1.0.246" 20 | pyln-proto = "^23.05.2" 21 | python-bitcoinlib = "^0.11.2" 22 | 23 | [tool.poetry.group.dev.dependencies] 24 | pytest = "^7.2.1" 25 | black = "^25.1.0" 26 | flake8 = "^4.0.1" 27 | pytest-xdist = "^3.1.0" 28 | mypy = "^1.15.0" 29 | 30 | [build-system] 31 | requires = ["poetry-core>=1.0.0"] 32 | build-backend = "poetry.core.masonry.api" 33 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/python3 2 | import pytest 3 | import importlib 4 | import lnprototest 5 | import pyln.spec.bolt1 6 | import pyln.spec.bolt2 7 | import pyln.spec.bolt7 8 | from pyln.proto.message import MessageNamespace 9 | from typing import Any, Callable, Generator, List 10 | 11 | 12 | def pytest_addoption(parser: Any) -> None: 13 | parser.addoption( 14 | "--runner", 15 | action="store", 16 | help="runner to use", 17 | default="lnprototest.DummyRunner", 18 | ) 19 | parser.addoption( 20 | "--runner-args", 21 | action="append", 22 | help="parameters for runner to use", 23 | default=[], 24 | ) 25 | 26 | 27 | @pytest.fixture() # type: ignore 28 | def runner(pytestconfig: Any) -> Any: 29 | parts = pytestconfig.getoption("runner").rpartition(".") 30 | runner = importlib.import_module(parts[0]).__dict__[parts[2]](pytestconfig) 31 | yield runner 32 | runner.teardown() 33 | 34 | 35 | @pytest.fixture() 36 | def namespaceoverride( 37 | pytestconfig: Any, 38 | ) -> Generator[Callable[[MessageNamespace], None], None, None]: 39 | """Use this if you want to override the message namespace""" 40 | 41 | def _setter(newns: MessageNamespace) -> None: 42 | lnprototest.assign_namespace(newns) 43 | 44 | yield _setter 45 | # Restore it 46 | lnprototest.assign_namespace(lnprototest.peer_message_namespace()) 47 | 48 | 49 | @pytest.fixture() 50 | def with_proposal( 51 | pytestconfig: Any, 52 | ) -> Generator[Callable[[List[str]], None], None, None]: 53 | """Use this to add additional messages to the namespace 54 | Useful for testing proposed (but not yet merged) spec mods. Noop if it seems already merged. 55 | """ 56 | 57 | def _setter(proposal_csv: List[str]) -> None: 58 | # Testing first line is cheap, pretty effective. 59 | if proposal_csv[0] not in ( 60 | pyln.spec.bolt1.csv + pyln.spec.bolt2.csv + pyln.spec.bolt7.csv 61 | ): 62 | # We merge *csv*, because then you can add tlv entries; merging 63 | # namespaces with duplicate TLVs complains of a clash. 64 | lnprototest.assign_namespace( 65 | lnprototest.make_namespace( 66 | pyln.spec.bolt1.csv 67 | + pyln.spec.bolt2.csv 68 | + pyln.spec.bolt7.csv 69 | + proposal_csv 70 | ) 71 | ) 72 | 73 | yield _setter 74 | 75 | # Restore it 76 | lnprototest.assign_namespace(lnprototest.peer_message_namespace()) 77 | -------------------------------------------------------------------------------- /tests/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --strict-markers 3 | xfail_strict = True -------------------------------------------------------------------------------- /tests/test_bolt1-01-init.py: -------------------------------------------------------------------------------- 1 | """ 2 | Variation of init exchange. 3 | 4 | Spec: MUST respond to known feature bits as specified in [BOLT #9](09-features.md). 5 | """ 6 | 7 | import functools 8 | 9 | from typing import List, Any 10 | 11 | import pytest 12 | import pyln.spec.bolt1 13 | 14 | from pyln.proto.message import Message 15 | 16 | from lnprototest.stash import rcvd 17 | from lnprototest.utils.utils import run_runner 18 | from lnprototest import ( 19 | Runner, 20 | Event, 21 | Sequence, 22 | TryAll, 23 | Connect, 24 | Disconnect, 25 | EventError, 26 | ExpectMsg, 27 | Msg, 28 | has_bit, 29 | bitfield, 30 | bitfield_len, 31 | SpecFileError, 32 | ExpectDisconnect, 33 | ) 34 | 35 | 36 | # BOLT #1: The sending node: 37 | # ... 38 | # - SHOULD NOT set features greater than 13 in `globalfeatures`. 39 | def no_gf13(event: Event, msg: Message, runner: "Runner") -> None: 40 | for i in range(14, bitfield_len(msg.fields["globalfeatures"])): 41 | if has_bit(msg.fields["globalfeatures"], i): 42 | raise EventError(event, "globalfeatures bit {} set".format(i)) 43 | 44 | 45 | def no_feature( 46 | featurebits: List[int], event: Event, msg: Message, runner: "Runner" 47 | ) -> None: 48 | for bit in featurebits: 49 | if has_bit(msg.fields["features"], bit): 50 | raise EventError( 51 | event, "features set bit {} unexpected: {}".format(bit, msg.to_str()) 52 | ) 53 | 54 | 55 | def has_feature( 56 | featurebits: List[int], event: Event, msg: Message, runner: "Runner" 57 | ) -> None: 58 | for bit in featurebits: 59 | if not has_bit(msg.fields["features"], bit): 60 | raise EventError( 61 | event, "features set bit {} unset: {}".format(bit, msg.to_str()) 62 | ) 63 | 64 | 65 | def has_one_feature( 66 | featurebits: List[int], event: Event, msg: Message, runner: "Runner" 67 | ) -> None: 68 | has_any = False 69 | for bit in featurebits: 70 | if has_bit(msg.fields["features"], bit): 71 | has_any = True 72 | 73 | if not has_any: 74 | raise EventError(event, "none of {} set: {}".format(featurebits, msg.to_str())) 75 | 76 | 77 | def test_namespace_override(runner: Runner, namespaceoverride: Any) -> None: 78 | # Truncate the namespace to just BOLT1 79 | namespaceoverride(pyln.spec.bolt1.namespace) 80 | 81 | # Try to send a message that's not in BOLT1 82 | with pytest.raises(SpecFileError, match=r"Unknown msgtype open_channel"): 83 | Msg("open_channel") 84 | 85 | 86 | def test_echo_init(runner: Runner, namespaceoverride: Any) -> None: 87 | # We override default namespace since we only need BOLT1 88 | namespaceoverride(pyln.spec.bolt1.namespace) 89 | test = [ 90 | Connect(connprivkey="03"), 91 | ExpectMsg("init"), 92 | Msg( 93 | "init", 94 | globalfeatures=runner.runner_features(globals=True), 95 | features=runner.runner_features(), 96 | ), 97 | # optionally disconnect that first one 98 | Connect(connprivkey="02"), 99 | # You should always handle us echoing your own features back! 100 | ExpectMsg("init"), 101 | Msg("init", globalfeatures=rcvd(), features=rcvd()), 102 | ] 103 | 104 | run_runner(runner, test) 105 | 106 | 107 | def test_echo_init_after_disconnect(runner: Runner, namespaceoverride: Any) -> None: 108 | # We override default namespace since we only need BOLT1 109 | namespaceoverride(pyln.spec.bolt1.namespace) 110 | test = [ 111 | Connect(connprivkey="03"), 112 | ExpectMsg("init"), 113 | Msg( 114 | "init", 115 | globalfeatures=runner.runner_features(globals=True), 116 | features=runner.runner_features(), 117 | ), 118 | # optionally disconnect that first one 119 | Disconnect(), 120 | Connect(connprivkey="02"), 121 | # You should always handle us echoing your own features back! 122 | ExpectMsg("init"), 123 | Msg("init", globalfeatures=rcvd(), features=rcvd()), 124 | ] 125 | 126 | run_runner(runner, test) 127 | 128 | 129 | def test_init_check_received_msg(runner: Runner, namespaceoverride: Any) -> None: 130 | """TODO add comments""" 131 | namespaceoverride(pyln.spec.bolt1.namespace) 132 | sequences = [ 133 | Connect(connprivkey="03"), 134 | ExpectMsg("init"), 135 | Msg( 136 | "init", 137 | globalfeatures=runner.runner_features(globals=True), 138 | features=runner.runner_features(), 139 | ), 140 | # optionally disconnect that first one 141 | TryAll([], Disconnect()), 142 | Connect(connprivkey="02"), 143 | # Even if we don't send anything, it should send init. 144 | ExpectMsg("init", if_match=no_gf13), 145 | ] 146 | run_runner(runner, sequences) 147 | 148 | 149 | def test_init_invalid_globalfeatures(runner: Runner, namespaceoverride: Any) -> None: 150 | """TODO add comments""" 151 | namespaceoverride(pyln.spec.bolt1.namespace) 152 | sequences = [ 153 | Connect(connprivkey="03"), 154 | ExpectMsg("init"), 155 | Msg( 156 | "init", 157 | globalfeatures=runner.runner_features(globals=True), 158 | features=runner.runner_features(), 159 | ), 160 | # optionally disconnect that first one 161 | TryAll([], Disconnect()), 162 | Connect(connprivkey="02"), 163 | ExpectMsg("init", if_match=no_gf13), 164 | # BOLT #1: 165 | # The sending node:... 166 | # - SHOULD NOT set features greater than 13 in `globalfeatures`. 167 | Msg( 168 | "init", 169 | globalfeatures=runner.runner_features( 170 | globals=True, additional_features=[99] 171 | ), 172 | features=runner.runner_features(), 173 | ), 174 | ] 175 | run_runner(runner, sequences) 176 | 177 | 178 | def test_init_is_first_msg(runner: Runner, namespaceoverride: Any) -> None: 179 | """TODO add comments""" 180 | namespaceoverride(pyln.spec.bolt1.namespace) 181 | sequences = [ 182 | Connect(connprivkey="03"), 183 | ExpectMsg("init"), 184 | Msg( 185 | "init", 186 | globalfeatures=runner.runner_features(globals=True), 187 | features=runner.runner_features(), 188 | ), 189 | # optionally disconnect that first one 190 | TryAll([], Disconnect()), 191 | Connect(connprivkey="02"), 192 | # Minimal possible init message. 193 | # BOLT #1: 194 | # The sending node: 195 | # - MUST send `init` as the first Lightning message for any connection. 196 | ExpectMsg("init"), 197 | Msg( 198 | "init", 199 | globalfeatures=runner.runner_features(globals=True), 200 | features=runner.runner_features(), 201 | ), 202 | ] 203 | run_runner(runner, sequences) 204 | 205 | 206 | def test_init_check_free_featurebits(runner: Runner, namespaceoverride: Any) -> None: 207 | """Sanity check that bits 98 and 99 are not used!""" 208 | namespaceoverride(pyln.spec.bolt1.namespace) 209 | sequences = [ 210 | Connect(connprivkey="03"), 211 | ExpectMsg("init"), 212 | Msg( 213 | "init", 214 | globalfeatures=runner.runner_features(globals=True), 215 | features=runner.runner_features(), 216 | ), 217 | # optionally disconnect that first one 218 | TryAll([], Disconnect()), 219 | Connect(connprivkey="02"), 220 | ExpectMsg("init", if_match=functools.partial(no_feature, [98, 99])), 221 | # BOLT #1: 222 | # The receiving node:... 223 | # - upon receiving unknown _odd_ feature bits that are non-zero: 224 | # - MUST ignore the bit. 225 | # init msg with unknown odd local bit (99): no error 226 | Msg( 227 | "init", 228 | globalfeatures=runner.runner_features(globals=True), 229 | features=runner.runner_features(additional_features=[99]), 230 | ), 231 | ] 232 | run_runner(runner, sequences) 233 | 234 | 235 | def test_init_fail_connection_if_receive_an_even_unknown_featurebits( 236 | runner: Runner, namespaceoverride: Any 237 | ) -> None: 238 | """Sanity check that bits 98 and 99 are not used!""" 239 | namespaceoverride(pyln.spec.bolt1.namespace) 240 | sequences = [ 241 | Connect(connprivkey="03"), 242 | ExpectMsg("init"), 243 | Msg( 244 | "init", 245 | globalfeatures=runner.runner_features(globals=True), 246 | features=runner.runner_features(), 247 | ), 248 | # optionally disconnect that first one 249 | TryAll([], Disconnect()), 250 | Connect(connprivkey="02"), 251 | # BOLT #1: 252 | # The receiving node: ... 253 | # - upon receiving unknown _even_ feature bits that are non-zero: 254 | # - MUST fail the connection. 255 | ExpectMsg("init"), 256 | Msg( 257 | "init", 258 | globalfeatures=runner.runner_features(globals=True), 259 | features=runner.runner_features(additional_features=[98]), 260 | ), 261 | ExpectDisconnect(), 262 | ] 263 | run_runner(runner, sequences) 264 | 265 | 266 | def test_init_fail_connection_if_receive_an_even_unknown_globalfeaturebits( 267 | runner: Runner, namespaceoverride: Any 268 | ) -> None: 269 | """Sanity check that bits 98 and 99 are not used!""" 270 | namespaceoverride(pyln.spec.bolt1.namespace) 271 | sequences = [ 272 | Connect(connprivkey="03"), 273 | ExpectMsg("init"), 274 | Msg( 275 | "init", 276 | globalfeatures=runner.runner_features(globals=True), 277 | features=runner.runner_features(), 278 | ), 279 | # optionally disconnect that first one 280 | TryAll([], Disconnect()), 281 | Connect(connprivkey="02"), 282 | # init msg with unknown even global bit (98): you will error 283 | ExpectMsg("init"), 284 | Msg( 285 | "init", 286 | globalfeatures=runner.runner_features( 287 | globals=True, additional_features=[98] 288 | ), 289 | features=runner.runner_features(), 290 | ), 291 | ExpectDisconnect(), 292 | ] 293 | run_runner(runner, sequences) 294 | 295 | 296 | def test_init_fail_ask_for_option_data_loss_protect( 297 | runner: Runner, namespaceoverride: Any 298 | ) -> None: 299 | """Sanity check that bits 98 and 99 are not used!""" 300 | namespaceoverride(pyln.spec.bolt1.namespace) 301 | sequences = [ 302 | Connect(connprivkey="03"), 303 | ExpectMsg("init"), 304 | Msg( 305 | "init", 306 | globalfeatures=runner.runner_features(globals=True), 307 | features=runner.runner_features(), 308 | ), 309 | # optionally disconnect that first one 310 | TryAll([], Disconnect()), 311 | Connect(connprivkey="02"), 312 | # If you don't support `option_data_loss_protect`, you will be ok if 313 | # we ask for it. 314 | Sequence( 315 | [ 316 | ExpectMsg("init", if_match=functools.partial(no_feature, [0, 1])), 317 | Msg( 318 | "init", 319 | globalfeatures=runner.runner_features(globals=True), 320 | features=runner.runner_features(additional_features=[1]), 321 | ), 322 | ], 323 | enable=not runner.has_option("option_data_loss_protect"), 324 | ), 325 | ] 326 | run_runner(runner, sequences) 327 | 328 | 329 | def test_init_advertize_option_data_loss_protect( 330 | runner: Runner, namespaceoverride: Any 331 | ) -> None: 332 | """TODO""" 333 | namespaceoverride(pyln.spec.bolt1.namespace) 334 | sequences = [ 335 | Connect(connprivkey="03"), 336 | ExpectMsg("init"), 337 | Msg( 338 | "init", 339 | globalfeatures=runner.runner_features(globals=True), 340 | features=runner.runner_features(), 341 | ), 342 | # optionally disconnect that first one 343 | TryAll([], Disconnect()), 344 | Connect(connprivkey="02"), 345 | # If you support `option_data_loss_protect`, you will advertize it odd. 346 | Sequence( 347 | [ExpectMsg("init", if_match=functools.partial(has_feature, [1]))], 348 | enable=(runner.has_option("option_data_loss_protect") == "odd"), 349 | ), 350 | ] 351 | run_runner(runner, sequences) 352 | 353 | 354 | def test_init_required_option_data_loss_protect( 355 | runner: Runner, namespaceoverride: Any 356 | ) -> None: 357 | """TODO""" 358 | namespaceoverride(pyln.spec.bolt1.namespace) 359 | sequences = [ 360 | Connect(connprivkey="03"), 361 | ExpectMsg("init"), 362 | Msg( 363 | "init", 364 | globalfeatures=runner.runner_features(globals=True), 365 | features=runner.runner_features(), 366 | ), 367 | # optionally disconnect that first one 368 | TryAll([], Disconnect()), 369 | Connect(connprivkey="02"), 370 | # If you require `option_data_loss_protect`, you will advertize it even. 371 | Sequence( 372 | [ExpectMsg("init", if_match=functools.partial(has_feature, [0]))], 373 | enable=(runner.has_option("option_data_loss_protect") == "even"), 374 | ), 375 | ] 376 | run_runner(runner, sequences) 377 | 378 | 379 | def test_init_reject_option_data_loss_protect_if_not_supported( 380 | runner: Runner, namespaceoverride: Any 381 | ) -> None: 382 | """TODO""" 383 | namespaceoverride(pyln.spec.bolt1.namespace) 384 | sequences = [ 385 | Connect(connprivkey="03"), 386 | ExpectMsg("init"), 387 | Msg( 388 | "init", 389 | globalfeatures=runner.runner_features(globals=True), 390 | features=runner.runner_features(), 391 | ), 392 | # optionally disconnect that first one 393 | TryAll([], Disconnect()), 394 | Connect(connprivkey="02"), 395 | # If you don't support `option_anchor_outputs`, you will error if 396 | # we require it. 397 | Sequence( 398 | [ 399 | ExpectMsg("init", if_match=functools.partial(no_feature, [20, 21])), 400 | Msg("init", globalfeatures="", features=bitfield(20)), 401 | ExpectDisconnect(), 402 | ], 403 | enable=not runner.has_option("option_anchor_outputs"), 404 | ), 405 | ] 406 | run_runner(runner, sequences) 407 | 408 | 409 | def test_init_advertize_option_anchor_outputs( 410 | runner: Runner, namespaceoverride: Any 411 | ) -> None: 412 | """TODO""" 413 | namespaceoverride(pyln.spec.bolt1.namespace) 414 | sequences = [ 415 | Connect(connprivkey="03"), 416 | ExpectMsg("init"), 417 | Msg( 418 | "init", 419 | globalfeatures=runner.runner_features(globals=True), 420 | features=runner.runner_features(), 421 | ), 422 | # optionally disconnect that first one 423 | TryAll([], Disconnect()), 424 | Connect(connprivkey="02"), 425 | # If you support `option_anchor_outputs`, you will advertize it odd. 426 | Sequence( 427 | [ExpectMsg("init", if_match=functools.partial(has_feature, [21]))], 428 | enable=(runner.has_option("option_anchor_outputs") == "odd"), 429 | ), 430 | ] 431 | run_runner(runner, sequences) 432 | 433 | 434 | def test_init_required_option_anchor_outputs( 435 | runner: Runner, namespaceoverride: Any 436 | ) -> None: 437 | """TODO""" 438 | namespaceoverride(pyln.spec.bolt1.namespace) 439 | sequences = [ 440 | Connect(connprivkey="03"), 441 | ExpectMsg("init"), 442 | Msg( 443 | "init", 444 | globalfeatures=runner.runner_features(globals=True), 445 | features=runner.runner_features(), 446 | ), 447 | # optionally disconnect that first one 448 | TryAll([], Disconnect()), 449 | Connect(connprivkey="02"), 450 | # If you require `option_anchor_outputs`, you will advertize it even. 451 | Sequence( 452 | [ExpectMsg("init", if_match=functools.partial(has_feature, [20]))], 453 | enable=(runner.has_option("option_anchor_outputs") == "even"), 454 | ), 455 | ] 456 | run_runner(runner, sequences) 457 | 458 | 459 | def test_init_advertize_option_static_remotekey( 460 | runner: Runner, namespaceoverride: Any 461 | ) -> None: 462 | """TODO""" 463 | namespaceoverride(pyln.spec.bolt1.namespace) 464 | sequences = [ 465 | Connect(connprivkey="03"), 466 | ExpectMsg("init"), 467 | Msg( 468 | "init", 469 | globalfeatures=runner.runner_features(globals=True), 470 | features=runner.runner_features(), 471 | ), 472 | # optionally disconnect that first one 473 | TryAll([], Disconnect()), 474 | Connect(connprivkey="02"), 475 | # BOLT-a12da24dd0102c170365124782b46d9710950ac1 #9: 476 | # | Bits | Name | ... | Dependencies 477 | # ... 478 | # | 12/13 | `option_static_remotekey` | 479 | # ... 480 | # | 20/21 | `option_anchor_outputs` | ... | `option_static_remotekey` | 481 | # If you support `option_anchor_outputs`, you will 482 | # advertize option_static_remotekey. 483 | Sequence( 484 | [ExpectMsg("init", if_match=functools.partial(has_one_feature, [12, 13]))], 485 | enable=(runner.has_option("option_anchor_outputs") is not None), 486 | ), 487 | ] 488 | run_runner(runner, sequences) 489 | -------------------------------------------------------------------------------- /tests/test_bolt1-02-unknown-messages.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # Init exchange, with unknown messages 3 | # 4 | import pyln.spec.bolt1 5 | 6 | from typing import Any 7 | 8 | from lnprototest import Connect, ExpectMsg, Msg, RawMsg, Runner 9 | from lnprototest.event import ExpectDisconnect 10 | from lnprototest.utils import run_runner 11 | 12 | 13 | def test_unknowns(runner: Runner, namespaceoverride: Any) -> None: 14 | # We override default namespace since we only need BOLT1 15 | namespaceoverride(pyln.spec.bolt1.namespace) 16 | test = [ 17 | Connect(connprivkey="03"), 18 | ExpectMsg("init"), 19 | Msg( 20 | "init", 21 | globalfeatures=runner.runner_features(globals=True), 22 | features=runner.runner_features(), 23 | ), 24 | # BOLT #1: 25 | # A receiving node: 26 | # - upon receiving a message of _odd_, unknown type: 27 | # - MUST ignore the received message. 28 | RawMsg(bytes.fromhex("270F")), 29 | ] 30 | run_runner(runner, test) 31 | 32 | 33 | def test_unknowns_even_message(runner: Runner, namespaceoverride: Any) -> None: 34 | # We override default namespace since we only need BOLT1 35 | namespaceoverride(pyln.spec.bolt1.namespace) 36 | test = [ 37 | Connect(connprivkey="03"), 38 | ExpectMsg("init"), 39 | Msg( 40 | "init", 41 | globalfeatures=runner.runner_features(globals=True), 42 | features=runner.runner_features(), 43 | ), 44 | # BOLT #1: 45 | # A receiving node:... 46 | # - upon receiving a message of _even_, unknown type: 47 | # - MUST close the connection. 48 | # - MAY fail the channels. 49 | RawMsg(bytes.fromhex("2710")), 50 | ExpectDisconnect(), 51 | ] 52 | run_runner(runner, test) 53 | -------------------------------------------------------------------------------- /tests/test_bolt2-01-close_channel.py: -------------------------------------------------------------------------------- 1 | """ 2 | testing bolt2 closing channel operation described in the lightning network speck 3 | https://github.com/lightning/bolts/blob/master/02-peer-protocol.md#channel-close 4 | 5 | The overview of what we test in this integration testing is described by the following 6 | figure. 7 | 8 | +-------+ +-------+ 9 | | |--(1)----- shutdown ------->| | 10 | | |<-(2)----- shutdown --------| | 11 | | | | | 12 | | | | | 13 | | | | | 14 | | A | ... | B | 15 | | | | | 16 | | |--(3)-- closing_signed F1--->| | 17 | | |<-(4)-- closing_signed F2----| | 18 | | | ... | | 19 | | |--(?)-- closing_signed Fn--->| | 20 | | |<-(?)-- closing_signed Fn----| | 21 | +-------+ +-------+ 22 | 23 | BOLT 2 proposal https://github.com/lightning/bolts/pull/972 24 | 25 | author: https://github.com/vincenzopalazzo 26 | """ 27 | 28 | import pytest 29 | 30 | from lnprototest import ( 31 | ExpectMsg, 32 | Msg, 33 | Runner, 34 | MustNotMsg, 35 | ) 36 | from lnprototest.utils import run_runner, merge_events_sequences, tx_spendable 37 | from lnprototest.stash import channel_id 38 | from lnprototest.utils.ln_spec_utils import ( 39 | open_and_announce_channel_helper, 40 | connect_to_node_helper, 41 | ) 42 | from lnprototest.utils import BitcoinUtils, ScriptType 43 | from lnprototest.event import CloseChannel 44 | 45 | 46 | def test_close_channel_shutdown_msg_normal_case_receiver_side(runner: Runner) -> None: 47 | """Close the channel with the other peer, and check if the 48 | shutdown message works in the expected way. 49 | 50 | In particular, this test will check the receiver side. 51 | 52 | ________________________________ 53 | | runner -> shutdown -> ln-node | 54 | | runner <- shutdown <- ln-node | 55 | -------------------------------- 56 | """ 57 | # the option that the helper method feels for us 58 | test_opts = {} 59 | pre_events_conn = connect_to_node_helper( 60 | runner=runner, 61 | tx_spendable=tx_spendable, 62 | conn_privkey="03", 63 | ) 64 | pre_events = open_and_announce_channel_helper( 65 | runner, conn_privkey="03", opts=test_opts 66 | ) 67 | # merge the two events 68 | pre_events = merge_events_sequences(pre_events_conn, pre_events) 69 | channel_idx = channel_id() 70 | 71 | script = BitcoinUtils.build_valid_script() 72 | test = [ 73 | # runner sent shutdown message to ln implementation 74 | # BOLT 2: 75 | # - MUST NOT send an `update_add_htlc` after a shutdown. 76 | Msg( 77 | "shutdown", 78 | channel_id=channel_idx, 79 | scriptpubkey=script, 80 | ), 81 | MustNotMsg("update_add_htlc"), 82 | ExpectMsg( 83 | "shutdown", ignore=ExpectMsg.ignore_all_gossip, channel_id=channel_idx 84 | ), 85 | # TODO: including in bitcoin function the possibility to sign this values 86 | # Msg( 87 | # "closing_signed", 88 | # channel_id=channel_idx, 89 | # fee_satoshis='100', 90 | # signature="0000", 91 | # ), 92 | # ExpectMsg("closing_signed") 93 | ] 94 | run_runner(runner, merge_events_sequences(pre=pre_events, post=test)) 95 | 96 | 97 | @pytest.mark.skip("working in progress") 98 | def test_close_channel_shutdown_msg_normal_case_sender_side(runner: Runner) -> None: 99 | """Close the channel with the other peer, and check if the 100 | shutdown message works in the expected way. 101 | 102 | In particular, this test will check the sender side, with a event flow like: 103 | ________________________________ 104 | | ln-node -> shutdown -> runner | 105 | | ln-node <- shutdown <- runner | 106 | -------------------------------- 107 | """ 108 | # the option that the helper method feels for us 109 | test_opts = {} 110 | pre_events_conn = connect_to_node_helper( 111 | runner=runner, 112 | tx_spendable=tx_spendable, 113 | conn_privkey="03", 114 | ) 115 | pre_events = open_and_announce_channel_helper( 116 | runner, conn_privkey="03", opts=test_opts 117 | ) 118 | # merge the two events 119 | pre_events = merge_events_sequences(pre_events_conn, pre_events) 120 | 121 | short_channel_id = test_opts["short_channel_id"] 122 | test = [ 123 | # runner sent shutdown message to ln implementation 124 | # BOLT 2: 125 | # - MUST NOT send an `update_add_htlc` after a shutdown. 126 | CloseChannel(channel_id=short_channel_id), 127 | MustNotMsg("update_add_htlc"), 128 | Msg("shutdown", ignore=ExpectMsg.ignore_all_gossip, channel_id=channel_id()), 129 | ExpectMsg("closing_signed"), 130 | ] 131 | run_runner(runner, merge_events_sequences(pre=pre_events, post=test)) 132 | 133 | 134 | @pytest.mark.skip(reason="skipping this for now because looks like flacky on CI") 135 | def test_close_channel_shutdown_msg_wrong_script_pubkey_receiver_side( 136 | runner: Runner, 137 | ) -> None: 138 | """Test close operation from the receiver view point, in the case when 139 | the sender set a wrong script pub key not specified in the spec. 140 | ______________________________________________________ 141 | | runner -> shutdown (wrong script pub key) -> ln-node | 142 | | runner <- warning msg <- ln-node | 143 | ------------------------------------------------------- 144 | """ 145 | # the option that the helper method feels for us 146 | test_opts = {} 147 | pre_events_conn = connect_to_node_helper( 148 | runner=runner, 149 | tx_spendable=tx_spendable, 150 | conn_privkey="03", 151 | ) 152 | pre_events = open_and_announce_channel_helper( 153 | runner, conn_privkey="03", opts=test_opts 154 | ) 155 | # merge the two events 156 | pre_events = merge_events_sequences(pre_events_conn, pre_events) 157 | 158 | script = BitcoinUtils.build_valid_script(ScriptType.INVALID_CLOSE_SCRIPT) 159 | 160 | test = [ 161 | # runner sent shutdown message to the ln implementation 162 | Msg( 163 | "shutdown", 164 | channel_id=channel_id(), 165 | scriptpubkey=script, 166 | ), 167 | MustNotMsg("add_htlc"), 168 | MustNotMsg("shutdown"), 169 | ExpectMsg("warning", ignore=ExpectMsg.ignore_all_gossip), 170 | ] 171 | run_runner(runner, merge_events_sequences(pre=pre_events, post=test)) 172 | -------------------------------------------------------------------------------- /tests/test_bolt2-01-open_channel.py: -------------------------------------------------------------------------------- 1 | # Variations on open_channel, accepter + opener perspectives 2 | from lnprototest import ( 3 | TryAll, 4 | Sequence, 5 | ExpectDisconnect, 6 | Block, 7 | FundChannel, 8 | ExpectMsg, 9 | ExpectTx, 10 | Msg, 11 | RawMsg, 12 | AcceptFunding, 13 | CreateFunding, 14 | Commit, 15 | Runner, 16 | remote_funding_pubkey, 17 | remote_revocation_basepoint, 18 | remote_payment_basepoint, 19 | remote_htlc_basepoint, 20 | remote_per_commitment_point, 21 | remote_delayed_payment_basepoint, 22 | Side, 23 | CheckEq, 24 | msat, 25 | remote_funding_privkey, 26 | bitfield, 27 | Block, 28 | ) 29 | from lnprototest.stash import ( 30 | sent, 31 | rcvd, 32 | commitsig_to_send, 33 | commitsig_to_recv, 34 | channel_id, 35 | funding_txid, 36 | funding_tx, 37 | funding, 38 | stash_field_from_event, 39 | ) 40 | from lnprototest.utils import ( 41 | utxo, 42 | BitcoinUtils, 43 | tx_spendable, 44 | run_runner, 45 | merge_events_sequences, 46 | funding_amount_for_utxo, 47 | pubkey_of, 48 | gen_random_keyset, 49 | ) 50 | from lnprototest.utils.ln_spec_utils import ( 51 | connect_to_node_helper, 52 | open_and_announce_channel_helper, 53 | ) 54 | 55 | 56 | def test_open_channel_announce_features(runner: Runner) -> None: 57 | """Check that the announce features works correctly""" 58 | connections_events = connect_to_node_helper( 59 | runner=runner, tx_spendable=tx_spendable, conn_privkey="02" 60 | ) 61 | 62 | test_events = [ 63 | TryAll( 64 | # BOLT-a12da24dd0102c170365124782b46d9710950ac1 #9: 65 | # | 20/21 | `option_anchor_outputs` | Anchor outputs 66 | Msg("init", globalfeatures="", features=bitfield(13, 21)), 67 | # BOLT #9: 68 | # | 12/13 | `option_static_remotekey` | Static key for remote output 69 | Msg("init", globalfeatures="", features=bitfield(13)), 70 | # And not. 71 | Msg("init", globalfeatures="", features=""), 72 | ), 73 | ] 74 | run_runner(runner, merge_events_sequences(connections_events, test_events)) 75 | 76 | 77 | def test_open_channel_from_accepter_side(runner: Runner) -> None: 78 | """Check the open channel from an accepter view point""" 79 | local_funding_privkey = "20" 80 | local_keyset = gen_random_keyset(int(local_funding_privkey)) 81 | connections_events = connect_to_node_helper( 82 | runner=runner, 83 | tx_spendable=tx_spendable, 84 | conn_privkey="02", 85 | ) 86 | 87 | # Accepter side: we initiate a new channel. 88 | test_events = [ 89 | Msg( 90 | "open_channel", 91 | chain_hash=BitcoinUtils.blockchain_hash(), 92 | temporary_channel_id="00" * 32, 93 | funding_satoshis=funding_amount_for_utxo(0), 94 | push_msat=0, 95 | dust_limit_satoshis=546, 96 | max_htlc_value_in_flight_msat=4294967295, 97 | channel_reserve_satoshis=9998, 98 | htlc_minimum_msat=0, 99 | feerate_per_kw=253, 100 | # We use 5, because c-lightning runner uses 6, so this is different. 101 | to_self_delay=5, 102 | max_accepted_htlcs=483, 103 | funding_pubkey=pubkey_of(local_funding_privkey), 104 | revocation_basepoint=local_keyset.revocation_basepoint(), 105 | payment_basepoint=local_keyset.payment_basepoint(), 106 | delayed_payment_basepoint=local_keyset.delayed_payment_basepoint(), 107 | htlc_basepoint=local_keyset.htlc_basepoint(), 108 | first_per_commitment_point=local_keyset.per_commit_point(0), 109 | channel_flags=1, 110 | ), 111 | # Ignore unknown odd messages 112 | TryAll([], RawMsg(bytes.fromhex("270F"))), 113 | ExpectMsg( 114 | "accept_channel", 115 | temporary_channel_id=sent(), 116 | funding_pubkey=remote_funding_pubkey(), 117 | revocation_basepoint=remote_revocation_basepoint(), 118 | payment_basepoint=remote_payment_basepoint(), 119 | delayed_payment_basepoint=remote_delayed_payment_basepoint(), 120 | htlc_basepoint=remote_htlc_basepoint(), 121 | first_per_commitment_point=remote_per_commitment_point(0), 122 | minimum_depth=stash_field_from_event("accept_channel", dummy_val=3), 123 | channel_reserve_satoshis=9998, 124 | ), 125 | # Ignore unknown odd messages 126 | TryAll([], RawMsg(bytes.fromhex("270F"))), 127 | # Create and stash Funding object and FundingTx 128 | CreateFunding( 129 | *utxo(0), 130 | local_node_privkey="02", 131 | local_funding_privkey=local_funding_privkey, 132 | remote_node_privkey=runner.get_node_privkey(), 133 | remote_funding_privkey=remote_funding_privkey() 134 | ), 135 | Commit( 136 | funding=funding(), 137 | opener=Side.local, 138 | local_keyset=local_keyset, 139 | local_to_self_delay=rcvd("to_self_delay", int), 140 | remote_to_self_delay=sent("to_self_delay", int), 141 | local_amount=msat(sent("funding_satoshis", int)), 142 | remote_amount=0, 143 | local_dust_limit=546, 144 | remote_dust_limit=546, 145 | feerate=253, 146 | local_features=sent("init.features"), 147 | remote_features=rcvd("init.features"), 148 | ), 149 | Msg( 150 | "funding_created", 151 | temporary_channel_id=rcvd(), 152 | funding_txid=funding_txid(), 153 | funding_output_index=0, 154 | signature=commitsig_to_send(), 155 | ), 156 | ExpectMsg( 157 | "funding_signed", 158 | channel_id=channel_id(), 159 | signature=commitsig_to_recv(), 160 | ), 161 | # Mine it and get it deep enough to confirm channel. 162 | Block( 163 | blockheight=103, 164 | number=stash_field_from_event( 165 | "accept_channel", field_name="minimum_depth", dummy_val=3 166 | ), 167 | txs=[funding_tx()], 168 | ), 169 | ExpectMsg( 170 | "channel_ready", 171 | channel_id=channel_id(), 172 | second_per_commitment_point="032405cbd0f41225d5f203fe4adac8401321a9e05767c5f8af97d51d2e81fbb206", 173 | ), 174 | Msg( 175 | "channel_ready", 176 | channel_id=channel_id(), 177 | second_per_commitment_point="027eed8389cf8eb715d73111b73d94d2c2d04bf96dc43dfd5b0970d80b3617009d", 178 | ), 179 | # Ignore unknown odd messages 180 | TryAll([], RawMsg(bytes.fromhex("270F"))), 181 | ] 182 | run_runner(runner, merge_events_sequences(connections_events, test_events)) 183 | 184 | 185 | def test_open_channel_opener_side(runner: Runner) -> None: 186 | local_funding_privkey = "20" 187 | local_keyset = gen_random_keyset(int(local_funding_privkey)) 188 | connections_events = connect_to_node_helper( 189 | runner=runner, 190 | tx_spendable=tx_spendable, 191 | conn_privkey="02", 192 | ) 193 | 194 | # Now we test the 'opener' side of an open_channel (node initiates) 195 | test_events = [ 196 | FundChannel(amount=999877), 197 | # This gives a channel of 999877sat 198 | ExpectMsg( 199 | "open_channel", 200 | chain_hash=BitcoinUtils.blockchain_hash(), 201 | funding_satoshis=999877, 202 | push_msat=0, 203 | dust_limit_satoshis=stash_field_from_event("open_channel", dummy_val=546), 204 | htlc_minimum_msat=stash_field_from_event("open_channel", dummy_val=0), 205 | channel_reserve_satoshis=9998, 206 | to_self_delay=stash_field_from_event("open_channel", dummy_val=6), 207 | funding_pubkey=remote_funding_pubkey(), 208 | revocation_basepoint=remote_revocation_basepoint(), 209 | payment_basepoint=remote_payment_basepoint(), 210 | delayed_payment_basepoint=remote_delayed_payment_basepoint(), 211 | htlc_basepoint=remote_htlc_basepoint(), 212 | first_per_commitment_point=remote_per_commitment_point(0), 213 | # FIXME: Check more fields! 214 | channel_flags="01", 215 | ), 216 | Msg( 217 | "accept_channel", 218 | temporary_channel_id=rcvd(), 219 | dust_limit_satoshis=546, 220 | max_htlc_value_in_flight_msat=4294967295, 221 | channel_reserve_satoshis=9998, 222 | htlc_minimum_msat=0, 223 | minimum_depth=3, 224 | max_accepted_htlcs=483, 225 | # We use 5, because c-lightning runner uses 6, so this is different. 226 | to_self_delay=5, 227 | funding_pubkey=pubkey_of(local_funding_privkey), 228 | revocation_basepoint=local_keyset.revocation_basepoint(), 229 | payment_basepoint=local_keyset.payment_basepoint(), 230 | delayed_payment_basepoint=local_keyset.delayed_payment_basepoint(), 231 | htlc_basepoint=local_keyset.htlc_basepoint(), 232 | first_per_commitment_point=local_keyset.per_commit_point(0), 233 | ), 234 | # Ignore unknown odd messages 235 | TryAll([], RawMsg(bytes.fromhex("270F"))), 236 | ExpectMsg("funding_created", temporary_channel_id=rcvd("temporary_channel_id")), 237 | # Now we can finally stash the funding information. 238 | AcceptFunding( 239 | rcvd("funding_created.funding_txid"), 240 | funding_output_index=rcvd("funding_created.funding_output_index", int), 241 | funding_amount=rcvd("open_channel.funding_satoshis", int), 242 | local_node_privkey="02", 243 | local_funding_privkey=local_funding_privkey, 244 | remote_node_privkey=runner.get_node_privkey(), 245 | remote_funding_privkey=remote_funding_privkey(), 246 | ), 247 | Commit( 248 | funding=funding(), 249 | opener=Side.remote, 250 | local_keyset=local_keyset, 251 | local_to_self_delay=rcvd("open_channel.to_self_delay", int), 252 | remote_to_self_delay=sent("accept_channel.to_self_delay", int), 253 | local_amount=0, 254 | remote_amount=msat(rcvd("open_channel.funding_satoshis", int)), 255 | local_dust_limit=sent("accept_channel.dust_limit_satoshis", int), 256 | remote_dust_limit=rcvd("open_channel.dust_limit_satoshis", int), 257 | feerate=rcvd("open_channel.feerate_per_kw", int), 258 | local_features=sent("init.features"), 259 | remote_features=rcvd("init.features"), 260 | ), 261 | # Now we've created commit, we can check sig is valid! 262 | CheckEq(rcvd("funding_created.signature"), commitsig_to_recv()), 263 | Msg( 264 | "funding_signed", 265 | channel_id=channel_id(), 266 | signature=commitsig_to_send(), 267 | ), 268 | # It will broadcast tx 269 | ExpectTx(rcvd("funding_created.funding_txid")), 270 | # Mine three blocks to confirm channel. 271 | Block(blockheight=103, number=3), 272 | Msg( 273 | "channel_ready", 274 | channel_id=sent(), 275 | second_per_commitment_point=local_keyset.per_commit_point(1), 276 | ), 277 | ExpectMsg( 278 | "channel_ready", 279 | channel_id=sent(), 280 | second_per_commitment_point=remote_per_commitment_point(1), 281 | ), 282 | # Ignore unknown odd messages 283 | TryAll([], RawMsg(bytes.fromhex("270F"))), 284 | ] 285 | run_runner(runner, merge_events_sequences(connections_events, test_events)) 286 | 287 | 288 | def test_open_channel_opener_side_wrong_announcement_signatures(runner: Runner) -> None: 289 | """Testing the case where the channel is announces in the correct way but one node 290 | send the wrong signature inside the `announcement_signatures` message.""" 291 | from lnprototest.clightning import Runner as CLightningRunner 292 | 293 | connections_events = connect_to_node_helper( 294 | runner=runner, 295 | tx_spendable=tx_spendable, 296 | conn_privkey="02", 297 | ) 298 | opts = {} 299 | open_channel_events = open_and_announce_channel_helper(runner, "02", opts=opts) 300 | pre_events = merge_events_sequences(connections_events, open_channel_events) 301 | 302 | dummy_sign = "138c93afb2013c39f959e70a163c3d6d8128cf72f8ae143f87b9d1fd6bb0ad30321116b9c58d69fca9fb33c214f681b664e53d5640abc2fdb972dc62a5571053" 303 | short_channel_id = opts["short_channel_id"] 304 | 305 | is_cln = isinstance(runner, CLightningRunner) 306 | test_events = [ 307 | # BOLT 2: 308 | # 309 | # - Once both nodes have exchanged channel_ready (and optionally announcement_signatures), 310 | # the channel can be used to make payments via Hashed Time Locked Contracts. 311 | ExpectMsg( 312 | "announcement_signatures", 313 | channel_id=channel_id(), 314 | short_channel_id=short_channel_id, 315 | node_signature=stash_field_from_event( 316 | "announcement_signatures", dummy_val=dummy_sign 317 | ), 318 | bitcoin_signature=stash_field_from_event( 319 | "announcement_signatures", dummy_val=dummy_sign 320 | ), 321 | ignore=ExpectMsg.ignore_channel_update, 322 | ), 323 | # BOLT 7: 324 | # - if the node_signature OR the bitcoin_signature is NOT correct: 325 | # - MAY send a warning and close the connection, or send an error and fail the channel. 326 | # 327 | # In our case, we send an error and stop the open channel procedure. This approach is 328 | # considered overly strict since the peer can recover from it. However, this step is 329 | # optional. If the peer sends it, we assume that the signature must be correct. 330 | Msg( 331 | "announcement_signatures", 332 | channel_id=channel_id(), 333 | short_channel_id=short_channel_id, 334 | node_signature=stash_field_from_event( 335 | "announcement_signatures", dummy_val=dummy_sign 336 | ), 337 | bitcoin_signature=stash_field_from_event( 338 | "announcement_signatures", dummy_val=dummy_sign 339 | ), 340 | ), 341 | # FIXME: here there is an error but we are not able to catch 342 | # because core lightning is too fast in closing the connection. 343 | # 344 | # So we should change the OneOf to all exception and stop when 345 | # the first one succided 346 | Sequence(ExpectDisconnect(), enable=is_cln), 347 | Sequence(ExpectMsg("error"), enable=(not is_cln)), 348 | ] 349 | run_runner(runner, merge_events_sequences(pre_events, test_events)) 350 | -------------------------------------------------------------------------------- /tests/test_bolt2-02-reestablish.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # Variations on adding an HTLC. 3 | 4 | from lnprototest import ( 5 | TryAll, 6 | Sequence, 7 | Connect, 8 | Block, 9 | ExpectMsg, 10 | Msg, 11 | RawMsg, 12 | CreateFunding, 13 | Commit, 14 | Runner, 15 | Disconnect, 16 | remote_funding_pubkey, 17 | remote_revocation_basepoint, 18 | remote_payment_basepoint, 19 | remote_htlc_basepoint, 20 | remote_per_commitment_point, 21 | remote_delayed_payment_basepoint, 22 | Side, 23 | CheckEq, 24 | msat, 25 | remote_funding_privkey, 26 | negotiated, 27 | ) 28 | from lnprototest.stash import ( 29 | sent, 30 | rcvd, 31 | commitsig_to_send, 32 | commitsig_to_recv, 33 | channel_id, 34 | funding_txid, 35 | funding_tx, 36 | funding, 37 | stash_field_from_event, 38 | ) 39 | from lnprototest.utils import ( 40 | BitcoinUtils, 41 | run_runner, 42 | merge_events_sequences, 43 | utxo, 44 | tx_spendable, 45 | funding_amount_for_utxo, 46 | pubkey_of, 47 | gen_random_keyset, 48 | ) 49 | from lnprototest.utils.ln_spec_utils import connect_to_node_helper 50 | 51 | # FIXME: bolt9.featurebits? 52 | # BOLT #9: 53 | # | 12/13 | `option_static_remotekey` | Static key for remote output 54 | static_remotekey = 13 55 | 56 | # BOLT #9: 57 | # | 0/1 | `option_data_loss_protect` | Requires or supports extra `channel_reestablish` fields 58 | data_loss_protect = 1 59 | 60 | # BOLT-a12da24dd0102c170365124782b46d9710950ac1 #9: 61 | # | 20/21 | `option_anchor_outputs` | Anchor outputs 62 | anchor_outputs = 21 63 | 64 | 65 | def test_reestablish(runner: Runner) -> None: 66 | local_funding_privkey = "20" 67 | local_keyset = gen_random_keyset(int(local_funding_privkey)) 68 | connections_events = connect_to_node_helper( 69 | runner=runner, 70 | tx_spendable=tx_spendable, 71 | conn_privkey="02", 72 | ) 73 | 74 | test_events = [ 75 | Msg( 76 | "open_channel", 77 | chain_hash=BitcoinUtils.blockchain_hash(), 78 | temporary_channel_id="00" * 32, 79 | funding_satoshis=funding_amount_for_utxo(0), 80 | push_msat=0, 81 | dust_limit_satoshis=546, 82 | max_htlc_value_in_flight_msat=4294967295, 83 | channel_reserve_satoshis=9998, 84 | htlc_minimum_msat=0, 85 | feerate_per_kw=253, 86 | # clightning uses to_self_delay=6; we use 5 to test differentiation 87 | to_self_delay=5, 88 | max_accepted_htlcs=483, 89 | funding_pubkey=pubkey_of(local_funding_privkey), 90 | revocation_basepoint=local_keyset.revocation_basepoint(), 91 | payment_basepoint=local_keyset.payment_basepoint(), 92 | delayed_payment_basepoint=local_keyset.delayed_payment_basepoint(), 93 | htlc_basepoint=local_keyset.htlc_basepoint(), 94 | first_per_commitment_point=local_keyset.per_commit_point(0), 95 | channel_flags=1, 96 | ), 97 | ExpectMsg( 98 | "accept_channel", 99 | funding_pubkey=remote_funding_pubkey(), 100 | revocation_basepoint=remote_revocation_basepoint(), 101 | payment_basepoint=remote_payment_basepoint(), 102 | delayed_payment_basepoint=remote_delayed_payment_basepoint(), 103 | htlc_basepoint=remote_htlc_basepoint(), 104 | first_per_commitment_point=remote_per_commitment_point(0), 105 | minimum_depth=stash_field_from_event("accept_channel", dummy_val=3), 106 | channel_reserve_satoshis=9998, 107 | ), 108 | # Create and stash Funding object and FundingTx 109 | CreateFunding( 110 | *utxo(0), 111 | local_node_privkey="02", 112 | local_funding_privkey=local_funding_privkey, 113 | remote_node_privkey=runner.get_node_privkey(), 114 | remote_funding_privkey=remote_funding_privkey() 115 | ), 116 | Commit( 117 | funding=funding(), 118 | opener=Side.local, 119 | local_keyset=local_keyset, 120 | local_to_self_delay=rcvd("to_self_delay", int), 121 | remote_to_self_delay=sent("to_self_delay", int), 122 | local_amount=msat(sent("funding_satoshis", int)), 123 | remote_amount=0, 124 | local_dust_limit=546, 125 | remote_dust_limit=546, 126 | feerate=253, 127 | local_features=sent("init.features"), 128 | remote_features=rcvd("init.features"), 129 | ), 130 | Msg( 131 | "funding_created", 132 | temporary_channel_id=rcvd(), 133 | funding_txid=funding_txid(), 134 | funding_output_index=0, 135 | signature=commitsig_to_send(), 136 | ), 137 | ExpectMsg( 138 | "funding_signed", channel_id=channel_id(), signature=commitsig_to_recv() 139 | ), 140 | # Mine it and get it deep enough to confirm channel. 141 | Block( 142 | blockheight=103, 143 | number=stash_field_from_event( 144 | "accept_channel", field_name="minimum_depth", dummy_val=3 145 | ), 146 | txs=[funding_tx()], 147 | ), 148 | ExpectMsg( 149 | "channel_ready", 150 | channel_id=channel_id(), 151 | second_per_commitment_point=remote_per_commitment_point(1), 152 | ), 153 | Msg( 154 | "channel_ready", 155 | channel_id=channel_id(), 156 | second_per_commitment_point=local_keyset.per_commit_point(1), 157 | ), 158 | Disconnect(), 159 | Connect(connprivkey="02"), 160 | ExpectMsg("init"), 161 | # Reconnect with same features. 162 | Msg("init", globalfeatures="", features=sent("init.features")), 163 | # BOLT #2: 164 | # - if `next_revocation_number` equals 0: 165 | # - MUST set `your_last_per_commitment_secret` to all zeroes 166 | # - otherwise: 167 | # - MUST set `your_last_per_commitment_secret` to the last 168 | # `per_commitment_secret` it received 169 | ExpectMsg( 170 | "channel_reestablish", 171 | channel_id=channel_id(), 172 | next_commitment_number=1, 173 | next_revocation_number=0, 174 | your_last_per_commitment_secret="00" * 32, 175 | ), 176 | # BOLT #2: 177 | # The sending node:... 178 | # - if `option_static_remotekey` applies to the commitment 179 | # transaction: 180 | # - MUST set `my_current_per_commitment_point` to a valid point. 181 | # - otherwise: 182 | # - MUST set `my_current_per_commitment_point` to its commitment 183 | # point for the last signed commitment it received from its 184 | # channel peer (i.e. the commitment_point corresponding to the 185 | # commitment transaction the sender would use to unilaterally 186 | # close). 187 | Sequence( 188 | CheckEq( 189 | rcvd("my_current_per_commitment_point"), remote_per_commitment_point(0) 190 | ), 191 | enable=negotiated( 192 | sent("init.features"), 193 | rcvd("init.features"), 194 | excluded=[static_remotekey], 195 | ), 196 | ), 197 | # Ignore unknown odd messages 198 | TryAll([], RawMsg(bytes.fromhex("270F"))), 199 | Msg( 200 | "channel_reestablish", 201 | channel_id=channel_id(), 202 | next_commitment_number=1, 203 | next_revocation_number=0, 204 | your_last_per_commitment_secret="00" * 32, 205 | my_current_per_commitment_point=local_keyset.per_commit_point(0), 206 | ), 207 | # FIXME: Check that they error and unilateral close if we give 208 | # the wrong info! 209 | ] 210 | 211 | run_runner(runner, merge_events_sequences(connections_events, test_events)) 212 | -------------------------------------------------------------------------------- /tests/test_bolt2-30-channel_type-open-accept-tlvs.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | 3 | from lnprototest import ( 4 | TryAll, 5 | Connect, 6 | Block, 7 | ExpectMsg, 8 | Msg, 9 | Runner, 10 | KeySet, 11 | bitfield, 12 | channel_type_csv, 13 | ExpectError, 14 | ) 15 | from lnprototest.utils import ( 16 | BitcoinUtils, 17 | tx_spendable, 18 | funding_amount_for_utxo, 19 | pubkey_of, 20 | ) 21 | from typing import Any 22 | import pytest 23 | 24 | 25 | def test_open_channel(runner: Runner, with_proposal: Any) -> None: 26 | """Tests for https://github.com/lightningnetwork/lightning-rfc/pull/880""" 27 | with_proposal(channel_type_csv) 28 | 29 | # This is not a feature bit, so use support_ to mark it. 30 | if runner.has_option("supports_open_accept_channel_type") is None: 31 | pytest.skip("Needs supports_open_accept_channel_type") 32 | 33 | local_funding_privkey = "20" 34 | 35 | local_keyset = KeySet( 36 | revocation_base_secret="21", 37 | payment_base_secret="22", 38 | htlc_base_secret="24", 39 | delayed_payment_base_secret="23", 40 | shachain_seed="00" * 32, 41 | ) 42 | 43 | test = [ 44 | Block(blockheight=102, txs=[tx_spendable]), 45 | Connect(connprivkey="02"), 46 | ExpectMsg("init"), 47 | Msg( 48 | "init", 49 | globalfeatures=runner.runner_features(globals=True), 50 | features=runner.runner_features(additional_features=[13]), 51 | ), 52 | Msg( 53 | "open_channel", 54 | chain_hash=BitcoinUtils.blockchain_hash(), 55 | temporary_channel_id="00" * 32, 56 | funding_satoshis=funding_amount_for_utxo(0), 57 | push_msat=0, 58 | dust_limit_satoshis=546, 59 | max_htlc_value_in_flight_msat=4294967295, 60 | channel_reserve_satoshis=9998, 61 | htlc_minimum_msat=0, 62 | feerate_per_kw=253, 63 | # We use 5, because core-lightning runner uses 6, so this is different. 64 | to_self_delay=5, 65 | max_accepted_htlcs=483, 66 | funding_pubkey=pubkey_of(local_funding_privkey), 67 | revocation_basepoint=local_keyset.revocation_basepoint(), 68 | payment_basepoint=local_keyset.payment_basepoint(), 69 | delayed_payment_basepoint=local_keyset.delayed_payment_basepoint(), 70 | htlc_basepoint=local_keyset.htlc_basepoint(), 71 | first_per_commitment_point=local_keyset.per_commit_point(0), 72 | channel_flags=1, 73 | # We negotiate *down* to a simple static channel 74 | tlvs="{channel_type={type=" + bitfield(12) + "}}", 75 | ), 76 | # BOLT #2 77 | # - if it sets `channel_type`: 78 | # - MUST set it to the `channel_type` from `open_channel` 79 | ExpectMsg("accept_channel", tlvs="{channel_type={type=" + bitfield(12) + "}}"), 80 | ] 81 | 82 | runner.run(test) 83 | 84 | 85 | def test_open_channel_bad_type(runner: Runner, with_proposal: Any) -> None: 86 | """Tests for https://github.com/lightningnetwork/lightning-rfc/pull/880""" 87 | with_proposal(channel_type_csv) 88 | 89 | # This is not a feature bit, so use support_ to mark it. 90 | if runner.has_option("supports_open_accept_channel_type") is None: 91 | pytest.skip("Needs supports_open_accept_channel_type") 92 | 93 | local_funding_privkey = "20" 94 | 95 | local_keyset = KeySet( 96 | revocation_base_secret="21", 97 | payment_base_secret="22", 98 | htlc_base_secret="24", 99 | delayed_payment_base_secret="23", 100 | shachain_seed="00" * 32, 101 | ) 102 | 103 | test = [ 104 | Block(blockheight=102, txs=[tx_spendable]), 105 | Connect(connprivkey="02"), 106 | ExpectMsg("init"), 107 | # BOLT #9: 108 | # | 12/13 | `option_static_remotekey` | Static key for remote output 109 | Msg( 110 | "init", 111 | globalfeatures=runner.runner_features(globals=True), 112 | features=runner.runner_features(additional_features=[12]), 113 | ), 114 | Msg( 115 | "open_channel", 116 | chain_hash=BitcoinUtils.blockchain_hash(), 117 | temporary_channel_id="00" * 32, 118 | funding_satoshis=funding_amount_for_utxo(0), 119 | push_msat=0, 120 | dust_limit_satoshis=546, 121 | max_htlc_value_in_flight_msat=4294967295, 122 | channel_reserve_satoshis=9998, 123 | htlc_minimum_msat=0, 124 | feerate_per_kw=253, 125 | # We use 5, because core-lightning runner uses 6, so this is different. 126 | to_self_delay=5, 127 | max_accepted_htlcs=483, 128 | funding_pubkey=pubkey_of(local_funding_privkey), 129 | revocation_basepoint=local_keyset.revocation_basepoint(), 130 | payment_basepoint=local_keyset.payment_basepoint(), 131 | delayed_payment_basepoint=local_keyset.delayed_payment_basepoint(), 132 | htlc_basepoint=local_keyset.htlc_basepoint(), 133 | first_per_commitment_point=local_keyset.per_commit_point(0), 134 | channel_flags=1, 135 | tlvs="{channel_type={type=" + bitfield(100) + "}}", 136 | ), 137 | # BOLT #2 138 | # The receiving node MUST fail the channel if: 139 | # - It supports `channel_types` and none of the `channel_types` 140 | # are suitable. 141 | ExpectError(), 142 | ] 143 | 144 | runner.run(test) 145 | -------------------------------------------------------------------------------- /tests/test_bolt7-01-channel_announcement-success.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # Simple gossip tests. 3 | 4 | from lnprototest import ( 5 | Connect, 6 | Block, 7 | ExpectMsg, 8 | Msg, 9 | RawMsg, 10 | Funding, 11 | Side, 12 | MustNotMsg, 13 | Disconnect, 14 | Runner, 15 | ) 16 | from lnprototest.utils import tx_spendable, utxo 17 | import time 18 | 19 | 20 | def test_gossip_forget_channel_after_12_blocks(runner: Runner) -> None: 21 | # Make up a channel between nodes 02 and 03, using bitcoin privkeys 10 and 20 22 | funding, funding_tx = Funding.from_utxo( 23 | *utxo(0), 24 | local_node_privkey="02", 25 | local_funding_privkey="10", 26 | remote_node_privkey="03", 27 | remote_funding_privkey="20" 28 | ) 29 | 30 | test = [ 31 | Block(blockheight=102, txs=[tx_spendable]), 32 | Connect(connprivkey="03"), 33 | ExpectMsg("init"), 34 | Msg( 35 | "init", 36 | globalfeatures=runner.runner_features(globals=True), 37 | features=runner.runner_features(), 38 | ), 39 | Block(blockheight=103, number=6, txs=[funding_tx]), 40 | RawMsg(funding.channel_announcement("103x1x0", "")), 41 | # New peer connects, asking for initial_routing_sync. We *won't* relay channel_announcement, as there is no 42 | # channel_update. 43 | Connect(connprivkey="05"), 44 | ExpectMsg("init"), 45 | Msg( 46 | "init", 47 | globalfeatures=runner.runner_features(globals=True), 48 | features=runner.runner_features(additional_features=[3]), 49 | ), 50 | MustNotMsg("channel_announcement"), 51 | Disconnect(), 52 | RawMsg( 53 | funding.channel_update( 54 | "103x1x0", 55 | Side.local, 56 | disable=False, 57 | cltv_expiry_delta=144, 58 | htlc_minimum_msat=0, 59 | fee_base_msat=1000, 60 | fee_proportional_millionths=10, 61 | timestamp=int(time.time()), 62 | htlc_maximum_msat=2000000, 63 | ), 64 | connprivkey="03", 65 | ), 66 | # Now we'll relay to a new peer. 67 | Connect(connprivkey="05"), 68 | ExpectMsg("init"), 69 | Msg( 70 | "init", 71 | globalfeatures=runner.runner_features(globals=True), 72 | features=runner.runner_features(additional_features=[3]), 73 | ), 74 | ExpectMsg("channel_announcement", short_channel_id="103x1x0"), 75 | ExpectMsg( 76 | "channel_update", 77 | short_channel_id="103x1x0", 78 | message_flags=1, 79 | channel_flags=0, 80 | ), 81 | Disconnect(), 82 | # BOLT #7: 83 | # - once its funding output has been spent OR reorganized out: 84 | # - SHOULD forget a channel after a 12-block delay. 85 | Block(blockheight=109, number=13, txs=[funding.close_tx(200, "99")]), 86 | Connect(connprivkey="05"), 87 | ExpectMsg("init"), 88 | Msg( 89 | "init", 90 | globalfeatures=runner.runner_features(globals=True), 91 | features=runner.runner_features(additional_features=[3]), 92 | ), 93 | MustNotMsg("channel_announcement"), 94 | MustNotMsg("channel_update"), 95 | ] 96 | 97 | runner.run(test) 98 | -------------------------------------------------------------------------------- /tests/test_bolt7-02-channel_announcement-failure.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # Tests for malformed/bad channel_announcement 3 | 4 | from lnprototest import ( 5 | Connect, 6 | Block, 7 | ExpectMsg, 8 | Msg, 9 | RawMsg, 10 | ExpectError, 11 | Funding, 12 | Side, 13 | MustNotMsg, 14 | Runner, 15 | TryAll, 16 | Sig, 17 | ) 18 | import time 19 | from typing import cast 20 | from lnprototest.utils import utxo, tx_spendable 21 | 22 | 23 | # FIXME: Make this work in-place! 24 | def corrupt_sig(sig: Sig) -> Sig: 25 | hashval = bytearray(cast(bytes, sig.hashval)) 26 | hashval[-1] ^= 1 27 | return Sig(sig.privkey.secret.hex(), hashval.hex()) 28 | 29 | 30 | def test_premature_channel_announcement(runner: Runner) -> None: 31 | # It's allowed (even encouraged!) to cache premature 32 | # channel_announcements, so we separate this from the other tests. 33 | 34 | funding, funding_tx = Funding.from_utxo( 35 | *utxo(0), 36 | local_node_privkey="02", 37 | local_funding_privkey="10", 38 | remote_node_privkey="03", 39 | remote_funding_privkey="20" 40 | ) 41 | 42 | test = [ 43 | Block(blockheight=102, txs=[tx_spendable]), 44 | Connect(connprivkey="03"), 45 | ExpectMsg("init"), 46 | Msg( 47 | "init", 48 | globalfeatures=runner.runner_features(globals=True), 49 | features=runner.runner_features(), 50 | ), 51 | # txid 189c40b0728f382fe91c87270926584e48e0af3a6789f37454afee6c7560311d 52 | Block(blockheight=103, txs=[funding_tx]), 53 | TryAll( 54 | # Invalid `channel_announcement`: short_channel_id too young. 55 | [RawMsg(funding.channel_announcement("103x1x0", ""))], 56 | # Invalid `channel_announcement`: short_channel_id *still* too young. 57 | [ 58 | Block(blockheight=104, number=4), 59 | RawMsg(funding.channel_announcement("103x1x0", "")), 60 | ], 61 | ), 62 | # Needs a channel_update if it were to relay. 63 | RawMsg( 64 | funding.channel_update( 65 | "103x1x0", 66 | Side.local, 67 | disable=False, 68 | cltv_expiry_delta=144, 69 | htlc_minimum_msat=0, 70 | fee_base_msat=1000, 71 | fee_proportional_millionths=10, 72 | timestamp=int(time.time()), 73 | htlc_maximum_msat=2000000, 74 | ) 75 | ), 76 | # New peer connects, asking for initial_routing_sync. We *won't* relay channel_announcement. 77 | Connect(connprivkey="05"), 78 | ExpectMsg("init"), 79 | Msg( 80 | "init", 81 | globalfeatures=runner.runner_features(globals=True), 82 | features=runner.runner_features(additional_features=[3]), 83 | ), 84 | MustNotMsg("channel_announcement"), 85 | MustNotMsg("channel_update"), 86 | ] 87 | 88 | runner.run(test) 89 | 90 | 91 | def test_bad_announcement(runner: Runner) -> None: 92 | funding, funding_tx = Funding.from_utxo( 93 | *utxo(0), 94 | local_node_privkey="02", 95 | local_funding_privkey="10", 96 | remote_node_privkey="03", 97 | remote_funding_privkey="20" 98 | ) 99 | 100 | # ### Ignored: 101 | ann_bad_chainhash = funding.channel_announcement("103x1x0", "") 102 | ann_bad_chainhash.fields["chain_hash"] = bytes.fromhex( 103 | "6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000" 104 | ) 105 | 106 | ann_bad_scid_dne = funding.channel_announcement("103x2x0", "") 107 | 108 | ann_bad_scid_out_dne = funding.channel_announcement("103x1x1", "") 109 | 110 | ann_bad_bitcoin_key1 = Funding( 111 | funding.txid, 112 | funding.output_index, 113 | funding.amount, 114 | local_node_privkey="02", 115 | local_funding_privkey="10", 116 | remote_node_privkey="03", 117 | remote_funding_privkey="21", 118 | ).channel_announcement("103x1x0", "") 119 | 120 | ann_bad_bitcoin_key2 = Funding( 121 | funding.txid, 122 | funding.output_index, 123 | funding.amount, 124 | local_node_privkey="02", 125 | local_funding_privkey="11", 126 | remote_node_privkey="03", 127 | remote_funding_privkey="20", 128 | ).channel_announcement("103x1x0", "") 129 | 130 | # ### These should cause an error 131 | ann_bad_nodesig1 = funding.channel_announcement("103x1x0", "") 132 | ann_bad_nodesig1.fields["node_signature_1"] = corrupt_sig( 133 | ann_bad_nodesig1.fields["node_signature_1"] 134 | ) 135 | 136 | ann_bad_nodesig2 = funding.channel_announcement("103x1x0", "") 137 | ann_bad_nodesig2.fields["node_signature_2"] = corrupt_sig( 138 | ann_bad_nodesig2.fields["node_signature_2"] 139 | ) 140 | 141 | ann_bad_bitcoinsig1 = funding.channel_announcement("103x1x0", "") 142 | ann_bad_bitcoinsig1.fields["bitcoin_signature_1"] = corrupt_sig( 143 | ann_bad_bitcoinsig1.fields["bitcoin_signature_1"] 144 | ) 145 | 146 | ann_bad_bitcoinsig2 = funding.channel_announcement("103x1x0", "") 147 | ann_bad_bitcoinsig2.fields["bitcoin_signature_2"] = corrupt_sig( 148 | ann_bad_bitcoinsig2.fields["bitcoin_signature_2"] 149 | ) 150 | 151 | test = [ 152 | Block(blockheight=102, txs=[tx_spendable]), 153 | Connect(connprivkey="03"), 154 | ExpectMsg("init"), 155 | Msg( 156 | "init", 157 | globalfeatures=runner.runner_features(globals=True), 158 | features=runner.runner_features(), 159 | ), 160 | # txid 189c40b0728f382fe91c87270926584e48e0af3a6789f37454afee6c7560311d 161 | Block(blockheight=103, number=6, txs=[funding_tx]), 162 | TryAll( 163 | # These are all ignored 164 | # BOLT #7: 165 | # - if the specified `chain_hash` is unknown to the receiver: 166 | # - MUST ignore the message. 167 | [ 168 | TryAll( 169 | [RawMsg(ann_bad_chainhash)], 170 | # BOLT #7: 171 | # - if the `short_channel_id`'s output does NOT correspond to a P2WSH (using 172 | # `bitcoin_key_1` and `bitcoin_key_2`, as specified in 173 | # [BOLT #3](03-transactions.md#funding-transaction-output)) OR the output is 174 | # spent: 175 | # - MUST ignore the message. 176 | [ 177 | RawMsg(ann_bad_scid_dne), 178 | # Needs a channel_update if it were to relay. 179 | RawMsg( 180 | funding.channel_update( 181 | "103x2x0", 182 | Side.local, 183 | disable=False, 184 | cltv_expiry_delta=144, 185 | htlc_minimum_msat=0, 186 | fee_base_msat=1000, 187 | fee_proportional_millionths=10, 188 | timestamp=int(time.time()), 189 | htlc_maximum_msat=2000000, 190 | ) 191 | ), 192 | ], 193 | [ 194 | RawMsg(ann_bad_scid_out_dne), 195 | # Needs a channel_update if it were to relay. 196 | RawMsg( 197 | funding.channel_update( 198 | "103x1x1", 199 | Side.local, 200 | disable=False, 201 | cltv_expiry_delta=144, 202 | htlc_minimum_msat=0, 203 | fee_base_msat=1000, 204 | fee_proportional_millionths=10, 205 | timestamp=int(time.time()), 206 | htlc_maximum_msat=2000000, 207 | ) 208 | ), 209 | ], 210 | [RawMsg(ann_bad_bitcoin_key1)], 211 | [RawMsg(ann_bad_bitcoin_key2)], 212 | ), 213 | # Needs a channel_update if it were to relay. 214 | RawMsg( 215 | funding.channel_update( 216 | "103x1x0", 217 | Side.local, 218 | disable=False, 219 | cltv_expiry_delta=144, 220 | htlc_minimum_msat=0, 221 | fee_base_msat=1000, 222 | fee_proportional_millionths=10, 223 | timestamp=int(time.time()), 224 | htlc_maximum_msat=2000000, 225 | ) 226 | ), 227 | # New peer connects, asking for initial_routing_sync. We *won't* relay channel_announcement. 228 | Connect(connprivkey="05"), 229 | ExpectMsg("init"), 230 | Msg( 231 | "init", 232 | globalfeatures=runner.runner_features(globals=True), 233 | features=runner.runner_features(additional_features=[3]), 234 | ), 235 | MustNotMsg("channel_announcement"), 236 | MustNotMsg("channel_update"), 237 | ], 238 | # BOLT #7: 239 | # - if the specified chain_hash is unknown to the receiver: 240 | # - MUST ignore the message. 241 | # - otherwise: 242 | # - if bitcoin_signature_1, bitcoin_signature_2, node_signature_1 OR node_signature_2 are invalid OR NOT correct: 243 | # - SHOULD send a warning. 244 | # - MAY close the connection. 245 | # - MUST ignore the message. 246 | [ 247 | TryAll( 248 | [RawMsg(ann_bad_nodesig1)], 249 | [RawMsg(ann_bad_nodesig2)], 250 | [RawMsg(ann_bad_bitcoinsig1)], 251 | [RawMsg(ann_bad_bitcoinsig2)], 252 | ), 253 | ExpectMsg("warning"), 254 | ], 255 | ), 256 | ] 257 | 258 | runner.run(test) 259 | -------------------------------------------------------------------------------- /tests/test_bolt7-10-gossip-filter.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # Tests for gossip_timestamp_filter 3 | from lnprototest import ( 4 | Connect, 5 | Block, 6 | ExpectMsg, 7 | Msg, 8 | RawMsg, 9 | Side, 10 | MustNotMsg, 11 | Disconnect, 12 | AnyOrder, 13 | Runner, 14 | Funding, 15 | bitfield, 16 | ) 17 | import pytest 18 | import time 19 | from lnprototest.utils import utxo, tx_spendable 20 | 21 | 22 | def test_gossip_timestamp_filter(runner: Runner) -> None: 23 | if runner.has_option("option_gossip_queries") is None: 24 | pytest.skip("Needs option_gossip_queries") 25 | 26 | funding1, funding1_tx = Funding.from_utxo( 27 | *utxo(0), 28 | local_node_privkey="02", 29 | local_funding_privkey="10", 30 | remote_node_privkey="03", 31 | remote_funding_privkey="20" 32 | ) 33 | 34 | funding2, funding2_tx = Funding.from_utxo( 35 | *utxo(1), 36 | local_node_privkey="04", 37 | local_funding_privkey="30", 38 | remote_node_privkey="05", 39 | remote_funding_privkey="40" 40 | ) 41 | 42 | timestamp1 = int(time.time()) 43 | timestamp2 = timestamp1 + 1 44 | 45 | test = [ 46 | Block(blockheight=102, txs=[tx_spendable]), 47 | Connect(connprivkey="03"), 48 | ExpectMsg("init"), 49 | Msg( 50 | "init", 51 | globalfeatures=runner.runner_features(globals=True), 52 | features=runner.runner_features(), 53 | ), 54 | # txid 189c40b0728f382fe91c87270926584e48e0af3a6789f37454afee6c7560311d 55 | Block(blockheight=103, number=6, txs=[funding1_tx]), 56 | RawMsg(funding1.channel_announcement("103x1x0", "")), 57 | RawMsg( 58 | funding1.node_announcement( 59 | Side.local, "", (1, 2, 3), "foobar", b"", timestamp1 60 | ) 61 | ), 62 | # New peer connects, asks for gossip_timestamp_filter=all. We *won't* relay channel_announcement, 63 | # as there is no channel_update. 64 | Connect(connprivkey="05"), 65 | ExpectMsg("init"), 66 | # BOLT #9: 67 | # | 6/7 | `gossip_queries` | More sophisticated gossip control 68 | Msg( 69 | "init", 70 | globalfeatures=runner.runner_features(globals=True), 71 | features=runner.runner_features(additional_features=[6]), 72 | ), 73 | Msg( 74 | "gossip_timestamp_filter", 75 | chain_hash="06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f", 76 | first_timestamp=0, 77 | timestamp_range=4294967295, 78 | ), 79 | MustNotMsg("channel_announcement"), 80 | MustNotMsg("channel_update"), 81 | MustNotMsg("node_announcement"), 82 | Disconnect(), 83 | # Now, with channel update 84 | RawMsg( 85 | funding1.channel_update( 86 | side=Side.local, 87 | short_channel_id="103x1x0", 88 | disable=False, 89 | cltv_expiry_delta=144, 90 | htlc_minimum_msat=0, 91 | fee_base_msat=1000, 92 | fee_proportional_millionths=10, 93 | timestamp=timestamp1, 94 | htlc_maximum_msat=2000000, 95 | ), 96 | connprivkey="03", 97 | ), 98 | # New peer connects, asks for gossip_timestamp_filter=all. update and node announcement will be relayed. 99 | Connect(connprivkey="05"), 100 | ExpectMsg("init"), 101 | Msg( 102 | "init", 103 | globalfeatures=runner.runner_features(globals=True), 104 | features=runner.runner_features(additional_features=[6]), 105 | ), 106 | Msg( 107 | "gossip_timestamp_filter", 108 | chain_hash="06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f", 109 | first_timestamp=0, 110 | timestamp_range=4294967295, 111 | ), 112 | ExpectMsg("channel_announcement", short_channel_id="103x1x0"), 113 | AnyOrder( 114 | ExpectMsg("channel_update", short_channel_id="103x1x0"), 115 | ExpectMsg("node_announcement"), 116 | ), 117 | Disconnect(), 118 | # BOLT #7: 119 | # The receiver: 120 | # - SHOULD send all gossip messages whose `timestamp` is greater or 121 | # equal to `first_timestamp`, and less than `first_timestamp` plus 122 | # `timestamp_range`. 123 | Connect(connprivkey="05"), 124 | ExpectMsg("init"), 125 | Msg( 126 | "init", 127 | globalfeatures=runner.runner_features(globals=True), 128 | features=runner.runner_features(additional_features=[6]), 129 | ), 130 | Msg( 131 | "gossip_timestamp_filter", 132 | chain_hash="06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f", 133 | first_timestamp=1000, 134 | timestamp_range=timestamp1 - 1000, 135 | ), 136 | MustNotMsg("channel_announcement"), 137 | MustNotMsg("channel_update"), 138 | MustNotMsg("node_announcement"), 139 | Disconnect(), 140 | Connect(connprivkey="05"), 141 | ExpectMsg("init"), 142 | Msg( 143 | "init", 144 | globalfeatures=runner.runner_features(globals=True), 145 | features=runner.runner_features(additional_features=[6]), 146 | ), 147 | Msg( 148 | "gossip_timestamp_filter", 149 | chain_hash="06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f", 150 | first_timestamp=timestamp1 + 1, 151 | timestamp_range=4294967295, 152 | ), 153 | MustNotMsg("channel_announcement"), 154 | MustNotMsg("channel_update"), 155 | MustNotMsg("node_announcement"), 156 | Disconnect(), 157 | # These two succeed in getting the gossip, then stay connected for next test. 158 | Connect(connprivkey="05"), 159 | ExpectMsg("init"), 160 | Msg( 161 | "init", 162 | globalfeatures=runner.runner_features(globals=True), 163 | features=runner.runner_features(additional_features=[6]), 164 | ), 165 | Msg( 166 | "gossip_timestamp_filter", 167 | chain_hash="06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f", 168 | first_timestamp=timestamp1, 169 | timestamp_range=4294967295, 170 | ), 171 | ExpectMsg("channel_announcement", short_channel_id="103x1x0"), 172 | AnyOrder( 173 | ExpectMsg("channel_update", short_channel_id="103x1x0"), 174 | ExpectMsg("node_announcement"), 175 | ), 176 | Connect(connprivkey="06"), 177 | ExpectMsg("init"), 178 | Msg( 179 | "init", 180 | globalfeatures=runner.runner_features(globals=True), 181 | features=runner.runner_features(additional_features=[6]), 182 | ), 183 | Msg( 184 | "gossip_timestamp_filter", 185 | chain_hash="06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f", 186 | first_timestamp=1000, 187 | timestamp_range=timestamp1 - 1000 + 1, 188 | ), 189 | ExpectMsg("channel_announcement", short_channel_id="103x1x0"), 190 | AnyOrder( 191 | ExpectMsg("channel_update", short_channel_id="103x1x0"), 192 | ExpectMsg("node_announcement"), 193 | ), 194 | # BOLT #7: 195 | # - SHOULD restrict future gossip messages to those whose `timestamp` 196 | # is greater or equal to `first_timestamp`, and less than 197 | # `first_timestamp` plus `timestamp_range`. 198 | Block(blockheight=109, number=6, txs=[funding2_tx]), 199 | RawMsg(funding2.channel_announcement("109x1x0", ""), connprivkey="03"), 200 | RawMsg( 201 | funding2.channel_update( 202 | side=Side.local, 203 | short_channel_id="109x1x0", 204 | disable=False, 205 | cltv_expiry_delta=144, 206 | htlc_minimum_msat=0, 207 | fee_base_msat=1000, 208 | fee_proportional_millionths=10, 209 | timestamp=timestamp2, 210 | htlc_maximum_msat=2000000, 211 | ) 212 | ), 213 | RawMsg( 214 | funding2.channel_update( 215 | side=Side.remote, 216 | short_channel_id="109x1x0", 217 | disable=False, 218 | cltv_expiry_delta=144, 219 | htlc_minimum_msat=0, 220 | fee_base_msat=1000, 221 | fee_proportional_millionths=10, 222 | timestamp=timestamp2, 223 | htlc_maximum_msat=2000000, 224 | ) 225 | ), 226 | RawMsg( 227 | funding2.node_announcement( 228 | Side.local, "", (1, 2, 3), "foobar2", b"", timestamp2 229 | ) 230 | ), 231 | # 005's filter covers this, 006's doesn't. 232 | ExpectMsg("channel_announcement", short_channel_id="109x1x0", connprivkey="05"), 233 | AnyOrder( 234 | ExpectMsg("channel_update", short_channel_id="109x1x0", channel_flags=0), 235 | ExpectMsg("channel_update", short_channel_id="109x1x0", channel_flags=1), 236 | ExpectMsg("node_announcement"), 237 | ), 238 | MustNotMsg("channel_announcement", connprivkey="06"), 239 | MustNotMsg("channel_update"), 240 | MustNotMsg("node_announcement"), 241 | ] 242 | 243 | runner.run(test) 244 | -------------------------------------------------------------------------------- /tests/test_bolt7-20-query_channel_range.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # Tests for gossip_timestamp_filter 3 | from lnprototest import ( 4 | Connect, 5 | Block, 6 | ExpectMsg, 7 | Msg, 8 | RawMsg, 9 | Funding, 10 | Event, 11 | Side, 12 | MustNotMsg, 13 | OneOf, 14 | Runner, 15 | bitfield, 16 | TryAll, 17 | Sequence, 18 | CheckEq, 19 | EventError, 20 | namespace, 21 | ) 22 | from lnprototest.utils import BitcoinUtils, tx_spendable, utxo 23 | from typing import Optional 24 | import pytest 25 | import time 26 | import io 27 | import zlib 28 | import crc32c 29 | from pyln.spec.bolt7 import channel_update_timestamps 30 | from pyln.proto.message import Message 31 | 32 | # Note for gossip_channel_range: we are *allowed* to return a superset 33 | # of what they ask, so if someone does that this test must be modified 34 | # to accept it (add an option_IMPL_gossip_query_superset?). 35 | # 36 | # Meanwhile, we assume an exact reply. 37 | 38 | 39 | def encode_timestamps(t1: int = 0, t2: int = 0) -> str: 40 | # BOLT #7: 41 | # For a single `channel_update`, timestamps are encoded as: 42 | # 43 | # 1. subtype: `channel_update_timestamps` 44 | # 2. data: 45 | # * [`u32`:`timestamp_node_id_1`] 46 | # * [`u32`:`timestamp_node_id_2`] 47 | # 48 | # Where: 49 | # * `timestamp_node_id_1` is the timestamp of the `channel_update` for 50 | # `node_id_1`, or 0 if there was no `channel_update` from that node. 51 | # * `timestamp_node_id_2` is the timestamp of the `channel_update` for 52 | # `node_id_2`, or 0 if there was no `channel_update` from that node. 53 | v, _ = channel_update_timestamps.val_from_str( 54 | "{{timestamp_node_id_1={},timestamp_node_id_2={}}}".format(t1, t2) 55 | ) 56 | 57 | buf = io.BytesIO() 58 | channel_update_timestamps.write(buf, v, {}) 59 | return buf.getvalue().hex() 60 | 61 | 62 | def decode_timestamps(runner: "Runner", event: Event, field: str) -> str: 63 | # Get timestamps from last reply_channel_range msg 64 | timestamps = runner.get_stash(event, "ExpectMsg")[-1][1]["tlvs"]["timestamps_tlv"] 65 | 66 | # BOLT #7: 67 | # Encoding types: 68 | # * `0`: uncompressed array of `short_channel_id` types, in ascending 69 | # order. 70 | # * `1`: array of `short_channel_id` types, in ascending order, compressed 71 | # with zlib deflate[1](#reference-1) 72 | if timestamps["encoding_type"] == 0: 73 | b = bytes.fromhex(timestamps["encoded_timestamps"]) 74 | elif timestamps["encoding_type"] == 1: 75 | b = zlib.decompress(bytes.fromhex(timestamps["encoded_timestamps"])) 76 | else: 77 | raise EventError(event, "Unknown encoding type: {}".format(timestamps)) 78 | 79 | return b.hex() 80 | 81 | 82 | def decode_scids(runner: "Runner", event: Event, field: str) -> str: 83 | # Nothing to decode if dummy runner. 84 | if runner._is_dummy(): 85 | return "" 86 | 87 | # Get encoded_short_ids from last msg. 88 | encoded = bytes.fromhex( 89 | runner.get_stash(event, "ExpectMsg")[-1][1]["encoded_short_ids"] 90 | ) 91 | # BOLT #7: 92 | # Encoding types: 93 | # * `0`: uncompressed array of `short_channel_id` types, in ascending 94 | # order. 95 | # * `1`: array of `short_channel_id` types, in ascending order, compressed 96 | # with zlib deflate[1](#reference-1) 97 | if encoded[0] == 0: 98 | b = encoded[1:] 99 | elif encoded[0] == 1: 100 | b = zlib.decompress(encoded[1:]) 101 | else: 102 | raise EventError( 103 | event, "Unknown encoding type {}: {}".format(encoded[0], encoded.hex()) 104 | ) 105 | 106 | scidtype = namespace().get_fundamentaltype("short_channel_id") 107 | arr = [] 108 | buf = io.BytesIO(b) 109 | while True: 110 | scid = scidtype.read(buf, {}) 111 | if scid is None: 112 | break 113 | arr.append(scid) 114 | 115 | return ",".join([scidtype.val_to_str(a, {}) for a in arr]) 116 | 117 | 118 | def calc_checksum(update: Message) -> int: 119 | # BOLT #7: The checksum of a `channel_update` is the CRC32C checksum as 120 | # specified in [RFC3720](https://tools.ietf.org/html/rfc3720#appendix-B.4) 121 | # of this `channel_update` without its `signature` and `timestamp` fields. 122 | bufio = io.BytesIO() 123 | update.write(bufio) 124 | buf = bufio.getvalue() 125 | 126 | # BOLT #7: 127 | # 1. type: 258 (`channel_update`) 128 | # 2. data: 129 | # * [`signature`:`signature`] 130 | # * [`chain_hash`:`chain_hash`] 131 | # * [`short_channel_id`:`short_channel_id`] 132 | # * [`u32`:`timestamp`] 133 | # * [`byte`:`message_flags`] 134 | 135 | # Note: 2 bytes for `type` field 136 | return crc32c.crc32c(buf[2 + 64 : 2 + 64 + 32 + 8] + buf[2 + 64 + 32 + 8 + 4 :]) 137 | 138 | 139 | def update_checksums(update1: Optional[Message], update2: Optional[Message]) -> str: 140 | # BOLT #7: 141 | # For a single `channel_update`, checksums are encoded as: 142 | # 143 | # 1. subtype: `channel_update_checksums` 144 | # 2. data: 145 | # * [`u32`:`checksum_node_id_1`] 146 | # * [`u32`:`checksum_node_id_2`] 147 | # 148 | # Where: 149 | # * `checksum_node_id_1` is the checksum of the `channel_update` for 150 | # `node_id_1`, or 0 if there was no `channel_update` from that node. 151 | # * `checksum_node_id_2` is the checksum of the `channel_update` for 152 | # `node_id_2`, or 0 if there was no `channel_update` from that node. 153 | if update1: 154 | csum1 = calc_checksum(update1) 155 | else: 156 | csum1 = 0 157 | 158 | if update2: 159 | csum2 = calc_checksum(update2) 160 | else: 161 | csum2 = 0 162 | 163 | return "{{checksum_node_id_1={},checksum_node_id_2={}}}".format(csum1, csum2) 164 | 165 | 166 | def test_query_channel_range(runner: Runner) -> None: 167 | if runner.has_option("option_gossip_queries") is None: 168 | pytest.skip("Needs option_gossip_queries") 169 | 170 | funding1, funding1_tx = Funding.from_utxo( 171 | *utxo(0), 172 | local_node_privkey="02", 173 | local_funding_privkey="10", 174 | remote_node_privkey="03", 175 | remote_funding_privkey="20" 176 | ) 177 | 178 | funding2, funding2_tx = Funding.from_utxo( 179 | *utxo(1), 180 | local_node_privkey="04", 181 | local_funding_privkey="30", 182 | remote_node_privkey="05", 183 | remote_funding_privkey="40" 184 | ) 185 | 186 | timestamp_103x1x0_LOCAL = int(time.time()) 187 | timestamp_109x1x0_LOCAL = timestamp_103x1x0_LOCAL - 1 188 | timestamp_109x1x0_REMOTE = timestamp_109x1x0_LOCAL - 1 189 | 190 | ts_103x1x0 = encode_timestamps(*funding1.node_id_sort(timestamp_103x1x0_LOCAL, 0)) 191 | ts_109x1x0 = encode_timestamps( 192 | *funding2.node_id_sort(timestamp_109x1x0_LOCAL, timestamp_109x1x0_REMOTE) 193 | ) 194 | 195 | update_103x1x0_LOCAL = funding1.channel_update( 196 | side=Side.local, 197 | short_channel_id="103x1x0", 198 | disable=False, 199 | cltv_expiry_delta=144, 200 | htlc_minimum_msat=0, 201 | fee_base_msat=1000, 202 | fee_proportional_millionths=10, 203 | timestamp=timestamp_103x1x0_LOCAL, 204 | htlc_maximum_msat=2000000, 205 | ) 206 | update_109x1x0_LOCAL = funding2.channel_update( 207 | side=Side.local, 208 | short_channel_id="109x1x0", 209 | disable=False, 210 | cltv_expiry_delta=144, 211 | htlc_minimum_msat=0, 212 | fee_base_msat=1000, 213 | fee_proportional_millionths=10, 214 | timestamp=timestamp_109x1x0_LOCAL, 215 | htlc_maximum_msat=2000000, 216 | ) 217 | update_109x1x0_REMOTE = funding2.channel_update( 218 | side=Side.remote, 219 | short_channel_id="109x1x0", 220 | disable=False, 221 | cltv_expiry_delta=144, 222 | htlc_minimum_msat=0, 223 | fee_base_msat=1000, 224 | fee_proportional_millionths=10, 225 | timestamp=timestamp_109x1x0_REMOTE, 226 | htlc_maximum_msat=2000000, 227 | ) 228 | 229 | csums_103x1x0 = update_checksums(*funding1.node_id_sort(update_103x1x0_LOCAL, None)) 230 | csums_109x1x0 = update_checksums( 231 | *funding2.node_id_sort(update_109x1x0_LOCAL, update_109x1x0_REMOTE) 232 | ) 233 | 234 | test = [ 235 | Block(blockheight=102, txs=[tx_spendable]), 236 | # Channel 103x1x0 (between 002 and 003) 237 | Block(blockheight=103, number=6, txs=[funding1_tx]), 238 | # Channel 109x1x0 (between 004 and 005) 239 | Block(blockheight=109, number=6, txs=[funding2_tx]), 240 | Connect(connprivkey="03"), 241 | ExpectMsg("init"), 242 | Msg( 243 | "init", 244 | globalfeatures=runner.runner_features(globals=True), 245 | features=runner.runner_features(), 246 | ), 247 | RawMsg(funding1.channel_announcement("103x1x0", "")), 248 | RawMsg(update_103x1x0_LOCAL), 249 | RawMsg(funding2.channel_announcement("109x1x0", "")), 250 | RawMsg(update_109x1x0_LOCAL), 251 | RawMsg(update_109x1x0_REMOTE), 252 | # New peer connects, with gossip_query option. 253 | Connect(connprivkey="05"), 254 | ExpectMsg("init"), 255 | # BOLT #9: 256 | # | 6/7 | `gossip_queries` | More sophisticated gossip control 257 | Msg( 258 | "init", 259 | globalfeatures=runner.runner_features(globals=True), 260 | features=runner.runner_features(additional_features=[7]), 261 | ), 262 | TryAll( 263 | # No queries? Must not get anything. 264 | [ 265 | MustNotMsg("channel_announcement"), 266 | MustNotMsg("channel_update"), 267 | MustNotMsg("node_announcement"), 268 | ], 269 | # This should elicit an empty response 270 | [ 271 | Msg( 272 | "query_channel_range", 273 | chain_hash=BitcoinUtils.blockchain_hash(), 274 | first_blocknum=0, 275 | number_of_blocks=103, 276 | ), 277 | ExpectMsg( 278 | "reply_channel_range", 279 | chain_hash=BitcoinUtils.blockchain_hash(), 280 | first_blocknum=0, 281 | number_of_blocks=103, 282 | ), 283 | CheckEq(decode_scids, ""), 284 | ], 285 | # This should get the first one, not the second. 286 | [ 287 | Msg( 288 | "query_channel_range", 289 | chain_hash=BitcoinUtils.blockchain_hash(), 290 | first_blocknum=103, 291 | number_of_blocks=1, 292 | ), 293 | ExpectMsg( 294 | "reply_channel_range", 295 | chain_hash=BitcoinUtils.blockchain_hash(), 296 | first_blocknum=103, 297 | number_of_blocks=1, 298 | ), 299 | CheckEq(decode_scids, "103x1x0"), 300 | ], 301 | # This should get the second one, not the first. 302 | [ 303 | Msg( 304 | "query_channel_range", 305 | chain_hash=BitcoinUtils.blockchain_hash(), 306 | first_blocknum=109, 307 | number_of_blocks=4294967295, 308 | ), 309 | OneOf( 310 | ExpectMsg( 311 | "reply_channel_range", 312 | chain_hash=BitcoinUtils.blockchain_hash(), 313 | first_blocknum=109, 314 | number_of_blocks=4294967186, 315 | ), 316 | # Could truncate number_of_blocks. 317 | ExpectMsg( 318 | "reply_channel_range", 319 | chain_hash=BitcoinUtils.blockchain_hash(), 320 | first_blocknum=109, 321 | number_of_blocks=1, 322 | ), 323 | ), 324 | CheckEq(decode_scids, "109x1x0"), 325 | ], 326 | # This should get both. 327 | [ 328 | Msg( 329 | "query_channel_range", 330 | chain_hash=BitcoinUtils.blockchain_hash(), 331 | first_blocknum=103, 332 | number_of_blocks=7, 333 | ), 334 | ExpectMsg( 335 | "reply_channel_range", 336 | chain_hash=BitcoinUtils.blockchain_hash(), 337 | first_blocknum=103, 338 | number_of_blocks=7, 339 | ), 340 | CheckEq(decode_scids, "103x1x0,109x1x0"), 341 | ], 342 | # This should get appended timestamp fields with option_gossip_queries_ex 343 | Sequence( 344 | enable=runner.has_option("option_gossip_queries_ex") is not None, 345 | events=[ 346 | Msg( 347 | "query_channel_range", 348 | chain_hash=BitcoinUtils.blockchain_hash(), 349 | first_blocknum=103, 350 | number_of_blocks=7, 351 | tlvs="{query_option={query_option_flags=1}}", 352 | ), 353 | ExpectMsg( 354 | "reply_channel_range", 355 | chain_hash=BitcoinUtils.blockchain_hash(), 356 | first_blocknum=103, 357 | number_of_blocks=7, 358 | ), 359 | CheckEq(decode_timestamps, ts_103x1x0 + ts_109x1x0), 360 | CheckEq(decode_scids, "103x1x0,109x1x0"), 361 | ], 362 | ), 363 | # This should get appended checksum fields with option_gossip_queries_ex 364 | Sequence( 365 | enable=runner.has_option("option_gossip_queries_ex") is not None, 366 | events=[ 367 | Msg( 368 | "query_channel_range", 369 | chain_hash=BitcoinUtils.blockchain_hash(), 370 | first_blocknum=103, 371 | number_of_blocks=7, 372 | tlvs="{query_option={query_option_flags=2}}", 373 | ), 374 | ExpectMsg( 375 | "reply_channel_range", 376 | chain_hash=BitcoinUtils.blockchain_hash(), 377 | first_blocknum=103, 378 | number_of_blocks=7, 379 | tlvs="{checksums_tlv={checksums=[" 380 | + csums_103x1x0 381 | + "," 382 | + csums_109x1x0 383 | + "]}}", 384 | ), 385 | CheckEq(decode_scids, "103x1x0,109x1x0"), 386 | ], 387 | ), 388 | # This should append timestamps and checksums with option_gossip_queries_ex 389 | Sequence( 390 | enable=runner.has_option("option_gossip_queries_ex") is not None, 391 | events=[ 392 | Msg( 393 | "query_channel_range", 394 | chain_hash=BitcoinUtils.blockchain_hash(), 395 | first_blocknum=103, 396 | number_of_blocks=7, 397 | tlvs="{query_option={query_option_flags=3}}", 398 | ), 399 | ExpectMsg( 400 | "reply_channel_range", 401 | chain_hash=BitcoinUtils.blockchain_hash(), 402 | first_blocknum=103, 403 | number_of_blocks=7, 404 | tlvs="{checksums_tlv={checksums=[" 405 | + csums_103x1x0 406 | + "," 407 | + csums_109x1x0 408 | + "]}}", 409 | ), 410 | CheckEq(decode_timestamps, ts_103x1x0 + ts_109x1x0), 411 | CheckEq(decode_scids, "103x1x0,109x1x0"), 412 | ], 413 | ), 414 | ), 415 | ] 416 | 417 | runner.run(test) 418 | -------------------------------------------------------------------------------- /tools/check_quotes.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/python3 2 | import fileinput 3 | import glob 4 | import re 5 | import sys 6 | from argparse import ArgumentParser, REMAINDER, Namespace 7 | from collections import namedtuple 8 | from typing import Dict, List, Tuple, Optional 9 | 10 | Quote = namedtuple("Quote", ["filename", "line", "text"]) 11 | whitespace_re = re.compile(r"\s+") 12 | 13 | 14 | def collapse_whitespace(string: str) -> str: 15 | return whitespace_re.sub(" ", string) 16 | 17 | 18 | def add_quote( 19 | boltquotes: Dict[int, List[Quote]], 20 | boltnum: int, 21 | filename: str, 22 | line: int, 23 | quote: str, 24 | ) -> None: 25 | if boltnum not in boltquotes: 26 | boltquotes[boltnum] = [] 27 | boltquotes[boltnum].append( 28 | Quote(filename, line, collapse_whitespace(quote.strip())) 29 | ) 30 | 31 | 32 | def included_commit(args: Namespace, boltprefix: str) -> bool: 33 | for inc in args.include_commit: 34 | if boltprefix.startswith(inc): 35 | return True 36 | return False 37 | 38 | 39 | # This looks like a BOLT line; return the bolt number and start of 40 | # quote if we shouldn't ignore it. 41 | def get_boltstart( 42 | args: Namespace, line: str, filename: str, linenum: int 43 | ) -> Tuple[Optional[int], Optional[str]]: 44 | if not line.startswith(args.comment_start + "BOLT"): 45 | return None, None 46 | 47 | parts = line[len(args.comment_start + "BOLT") :].partition(":") 48 | boltnum = parts[0].strip() 49 | 50 | # e.g. BOLT-50143e388e16a449a92ed574fc16eb35b51426b9 #11:" 51 | if boltnum.startswith("-"): 52 | if not included_commit(args, boltnum[1:]): 53 | return None, None 54 | boltnum = boltnum.partition(" ")[2] 55 | 56 | if not boltnum.startswith("#"): 57 | print( 58 | "{}:{}:expected # after BOLT in {}".format(filename, linenum, line), 59 | file=sys.stderr, 60 | ) 61 | sys.exit(1) 62 | 63 | try: 64 | boltint = int(boltnum[1:].strip()) 65 | except ValueError: 66 | print( 67 | "{}:{}:bad bolt number {}".format(filename, linenum, line), file=sys.stderr 68 | ) 69 | sys.exit(1) 70 | 71 | return boltint, parts[2] 72 | 73 | 74 | # We expect lines to start with '# BOLT #NN:' 75 | def gather_quotes(args: Namespace) -> Dict[int, List[Quote]]: 76 | boltquotes: Dict[int, List[Quote]] = {} 77 | curquote = None 78 | # These initializations simply keep flake8 happy 79 | curbolt = 0 80 | filestart = "" 81 | linestart = 0 82 | for file_line in fileinput.input(args.files): 83 | line = file_line.strip() 84 | boltnum, quote = get_boltstart( 85 | args, line, fileinput.filename(), fileinput.filelineno() 86 | ) 87 | if boltnum is not None: 88 | if curquote is not None: 89 | add_quote(boltquotes, curbolt, filestart, linestart, curquote) 90 | 91 | linestart = fileinput.filelineno() 92 | filestart = fileinput.filename() 93 | curbolt = boltnum 94 | curquote = quote 95 | elif curquote is not None: 96 | # If this is a continuation (and not an end!), add it. 97 | if ( 98 | args.comment_end is None or not line.startswith(args.comment_end) 99 | ) and line.startswith(args.comment_continue): 100 | # Special case where end marker is on same line. 101 | if args.comment_end is not None and line.endswith(args.comment_end): 102 | curquote += ( 103 | " " + line[len(args.comment_continue) : -len(args.comment_end)] 104 | ) 105 | add_quote(boltquotes, curbolt, filestart, linestart, curquote) 106 | curquote = None 107 | else: 108 | curquote += " " + line[len(args.comment_continue) :] 109 | else: 110 | add_quote(boltquotes, curbolt, filestart, linestart, curquote) 111 | curquote = None 112 | 113 | # Handle quote at eof. 114 | if curquote is not None: 115 | add_quote(boltquotes, curbolt, filestart, linestart, curquote) 116 | 117 | return boltquotes 118 | 119 | 120 | def load_bolt(boltdir: str, num: int) -> List[str]: 121 | """Return a list, divided into one-string-per-bolt-section, with 122 | whitespace collapsed into single spaces. 123 | 124 | """ 125 | boltfile = glob.glob("{}/{}-*md".format(boltdir, str(num).zfill(2))) 126 | if len(boltfile) == 0: 127 | print("Cannot find bolt {} in {}".format(num, boltdir), file=sys.stderr) 128 | sys.exit(1) 129 | elif len(boltfile) > 1: 130 | print( 131 | "More than one bolt {} in {}? {}".format(num, boltdir, boltfile), 132 | file=sys.stderr, 133 | ) 134 | sys.exit(1) 135 | 136 | # We divide it into sections, and collapse whitespace. 137 | boltsections = [] 138 | with open(boltfile[0]) as f: 139 | sect = "" 140 | for line in f.readlines(): 141 | if line.startswith("#"): 142 | # Append with whitespace collapsed. 143 | boltsections.append(collapse_whitespace(sect)) 144 | sect = "" 145 | sect += line 146 | boltsections.append(collapse_whitespace(sect)) 147 | 148 | return boltsections 149 | 150 | 151 | def find_quote( 152 | text: str, boltsections: List[str] 153 | ) -> Tuple[Optional[str], Optional[int]]: 154 | # '...' means "match anything". 155 | textparts = text.split("...") 156 | for b in boltsections: 157 | off = 0 158 | for part in textparts: 159 | off = b.find(part, off) 160 | if off == -1: 161 | break 162 | if off != -1: 163 | return b, off + len(part) 164 | return None, None 165 | 166 | 167 | def main(args: Namespace) -> None: 168 | boltquotes = gather_quotes(args) 169 | for bolt in boltquotes: 170 | boltsections = load_bolt(args.boltdir, bolt) 171 | for quote in boltquotes[bolt]: 172 | sect, end = find_quote(quote.text, boltsections) 173 | if not sect: 174 | print( 175 | "{}:{}:cannot find match".format(quote.filename, quote.line), 176 | file=sys.stderr, 177 | ) 178 | # Reduce the text until we find a match. 179 | for n in range(len(quote.text), -1, -1): 180 | sect, end = find_quote(quote.text[:n], boltsections) 181 | if sect: 182 | print( 183 | " common prefix: {}...".format(quote.text[:n]), 184 | file=sys.stderr, 185 | ) 186 | print( 187 | " expected ...{:.45}".format(sect[end:]), file=sys.stderr 188 | ) 189 | print( 190 | " but have ...{:.45}".format(quote.text[n:]), 191 | file=sys.stderr, 192 | ) 193 | break 194 | sys.exit(1) 195 | elif args.verbose: 196 | print( 197 | "{}:{}:Matched {} in {}".format( 198 | quote.filename, quote.line, quote.text, sect 199 | ) 200 | ) 201 | 202 | 203 | if __name__ == "__main__": 204 | parser = ArgumentParser( 205 | description="Check BOLT quotes in the given files are correct" 206 | ) 207 | parser.add_argument("-v", "--verbose", action="store_true") 208 | # e.g. for C code these are '/* ', '*' and '*/' 209 | parser.add_argument( 210 | "--comment-start", help='marker for start of "BOLT #N" quote', default="# " 211 | ) 212 | parser.add_argument( 213 | "--comment-continue", help='marker for continued "BOLT #N" quote', default="#" 214 | ) 215 | parser.add_argument("--comment-end", help='marker for end of "BOLT #N" quote') 216 | parser.add_argument( 217 | "--include-commit", 218 | action="append", 219 | help="Also parse BOLT- quotes", 220 | default=[], 221 | ) 222 | parser.add_argument( 223 | "--boltdir", help="Directory to look for BOLT tests", default="../lightning-rfc" 224 | ) 225 | parser.add_argument("files", help="Files to read in (or stdin)", nargs=REMAINDER) 226 | 227 | args = parser.parse_args() 228 | main(args) 229 | --------------------------------------------------------------------------------