├── .circleci └── config.yml ├── .coveragerc ├── .gitignore ├── CONTRIBUTING.md ├── DEPENDENCIES.md ├── INSTALL.md ├── LICENSE ├── README.md ├── common ├── README.md ├── __init__.py ├── appointment.py ├── config_loader.py ├── constants.py ├── cryptographer.py ├── db_manager.py ├── errors.py ├── exceptions.py ├── receipts.py ├── requirements.txt └── tools.py ├── contrib ├── __init__.py ├── client │ ├── DEPENDENCIES.md │ ├── INSTALL.md │ ├── README.md │ ├── __init__.py │ ├── help.py │ ├── requirements.txt │ ├── template.conf │ ├── teos_client.py │ └── test │ │ ├── __init__.py │ │ ├── conftest.py │ │ └── test_teos_client.py └── tools │ ├── __init__.py │ └── fill_subscription.py ├── docker ├── Dockerfile ├── arm32v7.Dockerfile ├── arm64v8.Dockerfile └── entrypoint.sh ├── requirements-dev.txt ├── requirements.txt ├── setup.py ├── teos ├── __init__.py ├── api.py ├── appointments_dbm.py ├── block_processor.py ├── builder.py ├── carrier.py ├── chain_monitor.py ├── cleaner.py ├── cli │ ├── README.md │ ├── __init__.py │ ├── rpc_client.py │ └── teos_cli.py ├── constants.py ├── extended_appointment.py ├── gatekeeper.py ├── gunicorn_config.py ├── help.py ├── inspector.py ├── internal_api.py ├── logger.py ├── protobuf │ ├── README.md │ ├── __init__.py │ ├── appointment_pb2.py │ ├── protos │ │ ├── appointment.proto │ │ ├── tower_services.proto │ │ └── user.proto │ ├── tower_services_pb2.py │ ├── tower_services_pb2_grpc.py │ └── user_pb2.py ├── responder.py ├── rpc.py ├── template.conf ├── teosd.py ├── tools.py ├── users_dbm.py ├── utils │ ├── __init__.py │ ├── auth_proxy.py │ └── rpc_errors.py └── watcher.py ├── test ├── __init__.py ├── common │ ├── __init__.py │ └── unit │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── test_appointment.py │ │ ├── test_config_loader.py │ │ ├── test_cryptographer.py │ │ ├── test_db_manager.py │ │ ├── test_receipts.py │ │ └── test_tools.py └── teos │ ├── __init__.py │ ├── bitcoin.conf │ ├── conftest.py │ ├── e2e │ ├── __init__.py │ ├── conftest.py │ ├── teos.conf │ ├── test_cli_e2e.py │ └── test_client_e2e.py │ └── unit │ ├── __init__.py │ ├── cli │ ├── test_rpc_client.py │ └── test_teos_cli.py │ ├── conftest.py │ ├── mocks.py │ ├── test_api.py │ ├── test_appointments_dbm.py │ ├── test_block_processor.py │ ├── test_builder.py │ ├── test_carrier.py │ ├── test_chain_monitor.py │ ├── test_cleaner.py │ ├── test_extended_appointment.py │ ├── test_gatekeeper.py │ ├── test_inspector.py │ ├── test_internal_api.py │ ├── test_logger.py │ ├── test_responder.py │ ├── test_tools.py │ ├── test_users_dbm.py │ └── test_watcher.py └── watchtower-plugin ├── README.md ├── arg_parser.py ├── keys.py ├── net └── http.py ├── requirements.txt ├── retrier.py ├── template.conf ├── test_watchtower.py ├── tower_info.py ├── towers_dbm.py └── watchtower.py /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | jobs: 4 | tests: 5 | machine: 6 | image: ubuntu-2004:202111-02 7 | 8 | steps: 9 | - checkout 10 | - restore_cache: 11 | keys: 12 | - v1-dependencies-20.0-{{ checksum "requirements.txt" }}-{{ checksum "requirements-dev.txt" }}-{{ checksum "contrib/client/requirements.txt" }}} 13 | 14 | - run: 15 | name: Install dependencies 16 | command: | 17 | sudo apt-get update && sudo apt-get install -y python3.8-venv 18 | python3.8 -m venv .venv 19 | source .venv/bin/activate 20 | python -m pip install --upgrade pip 21 | pip install -r requirements.txt 22 | pip install -r requirements-dev.txt 23 | pip install -r contrib/client/requirements.txt 24 | 25 | - run: 26 | name: Install bitcoind (0.20.0) 27 | command: | 28 | if [ ! -d "bitcoin" ]; then 29 | wget https://bitcoincore.org/bin/bitcoin-core-0.20.0/bitcoin-0.20.0-x86_64-linux-gnu.tar.gz 30 | tar -xzf bitcoin-0.20.0-x86_64-linux-gnu.tar.gz 31 | mv bitcoin-0.20.0 bitcoin 32 | fi 33 | 34 | - save_cache: 35 | paths: 36 | - .venv 37 | - bitcoin 38 | key: v1-dependencies-20.0-{{ checksum "requirements.txt" }}-{{ checksum "requirements-dev.txt" }}-{{ checksum "contrib/client/requirements.txt" }}} 39 | 40 | - run: 41 | name: Setup teos 42 | command: | 43 | mkdir ~/.teos/ 44 | cp test/teos/e2e/teos.conf ~/.teos/ 45 | 46 | - run: 47 | name: Run contrib unit tests 48 | command: | 49 | source .venv/bin/activate 50 | pytest contrib/**/test 51 | 52 | - run: 53 | name: Run common unit tests 54 | command: | 55 | source .venv/bin/activate 56 | pytest test/common/unit 57 | 58 | - run: 59 | name: Run teos unit tests 60 | command: | 61 | source .venv/bin/activate 62 | BITCOIND=/home/circleci/project/bitcoin/bin/bitcoind pytest test/teos/unit/ 63 | 64 | - run: 65 | name: Run e2e tests 66 | command: | 67 | source .venv/bin/activate 68 | BITCOIND=/home/circleci/project/bitcoin/bin/bitcoind pytest test/teos/e2e/ 69 | 70 | # Update Docker image 71 | # publish jobs require $DOCKERHUB_REPO, $DOCKERHUB_USER, $DOCKERHUB_PASS defined 72 | publish_docker_linuxamd64: 73 | machine: 74 | docker_layer_caching: false 75 | 76 | steps: 77 | - checkout 78 | - run: 79 | name: docker linux amd64 80 | command: | 81 | LATEST_TAG=${CIRCLE_TAG:1} #trim v from tag 82 | 83 | sudo docker build --pull -t $DOCKERHUB_REPO:$LATEST_TAG-amd64 -t $DOCKERHUB_REPO:latest-amd64 -f docker/Dockerfile . 84 | sudo docker login --username=$DOCKERHUB_USER --password=$DOCKERHUB_PASS 85 | sudo docker push $DOCKERHUB_REPO:$LATEST_TAG-amd64 86 | publish_docker_linuxarm32: 87 | machine: 88 | docker_layer_caching: false 89 | 90 | steps: 91 | - checkout 92 | - run: 93 | name: docker linux arm32 94 | no_output_timeout: 20m 95 | command: | 96 | sudo docker run --rm --privileged multiarch/qemu-user-static:register --reset 97 | LATEST_TAG=${CIRCLE_TAG:1} #trim v from tag 98 | 99 | sudo docker build --pull -t $DOCKERHUB_REPO:$LATEST_TAG-arm32v7 -t $DOCKERHUB_REPO:latest-arm32v7 -f docker/arm32v7.Dockerfile . 100 | sudo docker login --username=$DOCKERHUB_USER --password=$DOCKERHUB_PASS 101 | sudo docker push $DOCKERHUB_REPO:$LATEST_TAG-arm32v7 102 | publish_docker_linuxarm64: 103 | machine: 104 | docker_layer_caching: false 105 | 106 | steps: 107 | - checkout 108 | - run: 109 | name: docker linux arm64 110 | no_output_timeout: 20m 111 | command: | 112 | sudo docker run --rm --privileged multiarch/qemu-user-static:register --reset 113 | LATEST_TAG=${CIRCLE_TAG:1} #trim v from tag 114 | 115 | sudo docker build --pull -t $DOCKERHUB_REPO:$LATEST_TAG-arm64v8 -t $DOCKERHUB_REPO:latest-arm64v8 -f docker/arm64v8.Dockerfile . 116 | sudo docker login --username=$DOCKERHUB_USER --password=$DOCKERHUB_PASS 117 | sudo docker push $DOCKERHUB_REPO:$LATEST_TAG-arm64v8 118 | publish_docker_multiarch: 119 | machine: 120 | enabled: true 121 | image: circleci/classic:201808-01 122 | 123 | steps: 124 | - run: 125 | name: docker linux multiarch 126 | no_output_timeout: 20m 127 | command: | 128 | # Turn on Experimental features 129 | sudo mkdir $HOME/.docker 130 | sudo sh -c 'echo "{ \"experimental\": \"enabled\" }" >> $HOME/.docker/config.json' 131 | # 132 | sudo docker login --username=$DOCKERHUB_USER --password=$DOCKERHUB_PASS 133 | LATEST_TAG=${CIRCLE_TAG:1} #trim v from tag 134 | 135 | # Add the tag for the new version and update latest 136 | for TAG in $LATEST_TAG latest 137 | do 138 | sudo docker manifest create --amend $DOCKERHUB_REPO:$TAG $DOCKERHUB_REPO:$TAG-amd64 $DOCKERHUB_REPO:$TAG-arm32v7 $DOCKERHUB_REPO:$TAG-arm64v8 139 | sudo docker manifest annotate $DOCKERHUB_REPO:$TAG $DOCKERHUB_REPO:$TAG-amd64 --os linux --arch amd64 140 | sudo docker manifest annotate $DOCKERHUB_REPO:$TAG $DOCKERHUB_REPO:$TAG-arm32v7 --os linux --arch arm --variant v7 141 | sudo docker manifest annotate $DOCKERHUB_REPO:$TAG $DOCKERHUB_REPO:$TAG-arm64v8 --os linux --arch arm64 --variant v8 142 | sudo docker manifest push $DOCKERHUB_REPO:$TAG -p 143 | done 144 | 145 | workflows: 146 | version: 2 147 | 148 | run_tests: 149 | jobs: 150 | - tests 151 | 152 | publish: 153 | jobs: 154 | - publish_docker_linuxamd64: 155 | filters: 156 | # filters work like an or statement, specifying both tag and branch will make it run on the given branch 157 | # even if the tag does not match 158 | branches: 159 | ignore: /.*/ 160 | # Only act on tagged versions 161 | tags: 162 | only: /v[0-9]+(\.[0-9]+)*/ 163 | - publish_docker_linuxarm32: 164 | filters: 165 | branches: 166 | ignore: /.*/ 167 | tags: 168 | only: /v[0-9]+(\.[0-9]+)*/ 169 | - publish_docker_linuxarm64: 170 | filters: 171 | branches: 172 | ignore: /.*/ 173 | tags: 174 | only: /v[0-9]+(\.[0-9]+)*/ 175 | - publish_docker_multiarch: 176 | requires: 177 | - publish_docker_linuxamd64 178 | - publish_docker_linuxarm32 179 | - publish_docker_linuxarm64 180 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | *__init__.py 4 | teos/teosd.py 5 | teos/logger.py 6 | teos/sample_conf.py 7 | teos/utils/auth_proxy.py -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | .idea/ 3 | .venv* 4 | *.log 5 | .DS_Store 6 | bitcoin.conf* 7 | *__pycache__ 8 | .pending* 9 | cli/*.json 10 | appointments/ 11 | test.py 12 | *.pyc 13 | .cache 14 | .pytest_cache/ 15 | *.der 16 | .coverage 17 | htmlcov 18 | docs/ 19 | .teos 20 | .teos_cli 21 | *.orig 22 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to the Eye of Satoshi 2 | 3 | The following is a set of guidelines for contributing to TEOS. 4 | 5 | ## Code Style Guidelines 6 | We use [black](https://github.com/psf/black) as our base code formatter with a line length of 120 chars. Before submitting a PR make sure you have properly formatted your code by running: 7 | 8 | ```bash 9 | black --line-length=120 {source_file_or_directory} 10 | ``` 11 | 12 | In additon, we use [flake8](https://flake8.pycqa.org/en/latest/) to detect style issues with the code: 13 | 14 | ```bash 15 | flake8 --max-line-length=120 {source_file_or_directory} 16 | ``` 17 | 18 | Not all outputs from flake8 are mandatory. For instance, splitting **bullet points in docstrings (E501)** will cause issues when generating the documentation, so we will leave that longer than the line length limit . Another example are **whitespaces before colons in inline fors (E203)**. `black` places them in that way, so we'll leave them like that. 19 | 20 | On top of that, there are a few rules to also have in mind. 21 | 22 | ### Code Spacing 23 | Blocks of code should be created to separate logical sections 24 | 25 | ```python 26 | responder = Responder(db_manager) 27 | responder.jobs, responder.tx_job_map = Builder.build_jobs(responder_jobs_data) 28 | 29 | watcher.responder = responder 30 | watcher.appointments, watcher.locator_uuid_map = Builder.build_appointments(watcher_appointments_data) 31 | ``` 32 | We favour spacing between blocks like `if/else`, `try/except`, etc. 33 | 34 | ```python 35 | if tx in missed_confirmations: 36 | missed_confirmations[tx] += 1 37 | 38 | else: 39 | missed_confirmations[tx] = 1 40 | ``` 41 | 42 | An exception to the rule are nested `if` statements that placed right after each other and `if` statements with a single line of code. 43 | 44 | ```python 45 | for opt, arg in opts: 46 | if opt in ["-s", "server"]: 47 | if arg: 48 | teos_api_server = arg 49 | ``` 50 | 51 | ```python 52 | if appointment_data is None: 53 | raise InspectionFailed(errors.APPOINTMENT_EMPTY_FIELD, "empty appointment received") 54 | elif not isinstance(appointment_data, dict): 55 | raise InspectionFailed(errors.APPOINTMENT_WRONG_FIELD, "wrong appointment format") 56 | ``` 57 | 58 | ## Dev Requirements 59 | In order to contrubite you will need to install additional dependencies. They can be found at `requirements-dev.txt`. Install them by running: 60 | 61 | pip install -r requirements-dev.txt 62 | 63 | ## Code Documentation 64 | Code should be, at least, documented using docstrings. We use the [Sphinx Google Style](https://www.sphinx-doc.org/en/master/usage/extensions/example_google.html#example-google) for documenting functions. 65 | 66 | Here's an example of method docs: 67 | 68 | ``` 69 | """ 70 | Manages the add_appointment command. The life cycle of the function is as follows: 71 | - Sign the appointment 72 | - Send the appointment to the tower 73 | - Wait for the response 74 | - Check the tower's response and signature 75 | 76 | Args: 77 | appointment (:obj:`Appointment `): an appointment object. 78 | user_sk (:obj:`PrivateKey`): the user's private key. 79 | teos_id (:obj:`str`): the tower's compressed public key. 80 | teos_url (:obj:`str`): the teos base url. 81 | 82 | Returns: 83 | :obj:`tuple`: A tuple containing the start block and the tower's signature of the appointment. 84 | 85 | Raises: 86 | :obj:`ValueError`: if the appointment cannot be signed. 87 | :obj:`ConnectionError`: if the client cannot connect to the tower. 88 | :obj:`TowerResponseError`: if the tower responded with an error, or the response was invalid. 89 | """ 90 | ``` 91 | 92 | - In `Args`, custom types need to be linked (`Appointment `) to the proper file. 93 | Same happens within `Return`. `Raises` is special though. Exceptions must not be linked (or it will create a format error). 94 | - Text that wraps around the line limit need to be indented in `Args` and `Raises`, but not in `Return`. 95 | - Only `Returns` and `Attributes` docs are capitalized, not `Args`, nor `Raises`. 96 | - Variable names can be highlighted using \`\`var_name\`\`. For types, :obj:\`TypeName\` must be used. 97 | 98 | 99 | 100 | ## Test Coverage 101 | We use [pytest](https://docs.pytest.org/en/latest/) to build and run tests. Tests should be provided to cover both positive and negative conditions. Test should cover both the proper execution as well as all the covered error paths. PR with no proper test coverage will be rejected. 102 | 103 | ## Signing Commits 104 | 105 | We require that all commits to be merge into master are signed. You can enable commit signing on GitHub by following [Signing commits](https://help.github.com/en/github/authenticating-to-github/signing-commits). 106 | 107 | ## Installing the development client 108 | 109 | You can install the development client for easier testing by setting the development flag (DEV=1) when installing `teos`. 110 | 111 | ``` 112 | DEV=1 python setup.py install 113 | ``` 114 | -------------------------------------------------------------------------------- /DEPENDENCIES.md: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | 3 | `teos` has both system-wide and Python dependencies. This document walks you through how to satisfy them. 4 | 5 | ## System-wide Dependencies 6 | 7 | `teos` has the following system-wide dependencies: 8 | 9 | - `python3` version 3.7+ 10 | - `pip3` 11 | - `openssl` version 1.1+ 12 | - `bitcoind` 13 | 14 | Moreover, your system could need some additional dev libraries in order to run `teos`, such as `libffi-dev` and `libleveldb-dev`. 15 | This ultimately depends on your OS and distro. 16 | 17 | ### Checking if the dependencies are already satisfied 18 | 19 | Most UNIX systems ship with `python3` already installed, whereas OSX systems tend to ship with `python2`. In order to check our python version we should run: 20 | 21 | python --version 22 | 23 | For what we will get something like: 24 | 25 | Python 2.X.X 26 | 27 | Or 28 | 29 | Python 3.X.X 30 | 31 | It is also likely that, if `python3` is installed in our system, the `python` alias is not set to it but instead to `python2`. In order to check so, we can run: 32 | 33 | python3 --version 34 | 35 | If `python3` is installed but the `python` alias is not set to it, we should either set it, or use `python3` to run `teos`. 36 | 37 | Regarding `pip`, we can check what version is installed in our system (if any) by running: 38 | 39 | pip --version 40 | 41 | For what we will get something like: 42 | 43 | pip X.X.X from /usr/local/lib/python2.X/dist-packages/pip (python 2.X) 44 | 45 | Or 46 | 47 | pip X.X.X from /usr/local/lib/python3.X/dist-packages/pip (python 3.X) 48 | 49 | A similar thing to the `python` alias applies to the `pip` alias. We can check if pip3 is install by running: 50 | 51 | pip3 --version 52 | 53 | And, if it happens to be installed, change the alias to `pip3`, or use `pip3` instead of `pip`. 54 | 55 | ### Installing bitcoind 56 | 57 | `teos` runs on top of a Bitcoin Core node. Other underlaying Bitcoin nodes are not supported at the moment. 58 | 59 | You can get Bitcoin Core from [bitcoin.org](https://bitcoin.org/en/download). 60 | 61 | Bitcoin needs to be running with the following options enabled: 62 | 63 | - `zmq` for rawblockhash notifications 64 | - `txindex` to be able to look for non-wallet transactions 65 | - `server` to run rpc commands 66 | 67 | Here's an example of a `bitcoin.conf` you can use for mainnet: 68 | 69 | ``` 70 | # [rpc] 71 | server=1 72 | rpcuser=user 73 | rpcpassword=passwd 74 | rpcservertimeout=600 75 | 76 | # [zmq] 77 | zmqpubhashblock=tcp://127.0.0.1:28332 78 | zmqpubrawblock=tcp://127.0.0.1:28332 79 | zmqpubhashtx=tcp://127.0.0.1:28333 80 | zmqpubrawtx=tcp://127.0.0.1:28333 81 | 82 | # [blockchain] 83 | txindex=1 84 | 85 | # [others] 86 | daemon=1 87 | debug=1 88 | maxtxfee=1 89 | ``` 90 | 91 | ### Installing the Dependencies 92 | 93 | `python3` can be downloaded from the [Python official website](https://www.python.org/downloads/) or installed using a package manager, depending on your distribution. Examples for both UNIX-like and OSX systems are provided. 94 | 95 | #### Ubuntu 96 | 97 | `python3` can be installed using `apt` as follows: 98 | 99 | sudo apt-get update 100 | sudo apt-get install python3 101 | 102 | and for `pip3`: 103 | 104 | sudo apt-get install python3-pip 105 | pip install --upgrade pip==9.0.3 106 | 107 | #### OSX 108 | 109 | `python3` can be installed using `Homebrew` as follows: 110 | 111 | brew install python3 112 | 113 | `pip3` will be installed alongside `python3` in this case. 114 | 115 | ## Python Dependencies 116 | 117 | `teos` has several python dependencies that are automatically alongside the it. Should you need to install them manually, you can do so by running: 118 | 119 | ``` 120 | pip install -r requirements.txt 121 | ``` 122 | -------------------------------------------------------------------------------- /INSTALL.md: -------------------------------------------------------------------------------- 1 | # Install 2 | 3 | `teos` has some dependencies that can be satisfied by following [DEPENDENCIES.md](DEPENDENCIES.md). If your system already satisfies the dependencies, you can skip that part. 4 | 5 | There are two ways of installing `teos`, from source or getting it from PyPi (the Python Package Index). 6 | 7 | No matter the way you chose, once installed the executables for `teosd` and `teos-cli` will be available in the shell. 8 | 9 | ## Installing from source 10 | 11 | `teos` can be installed from source by running (from `python-teos/`): 12 | 13 | ``` 14 | pip install . 15 | ``` 16 | 17 | ## Installing via PyPi 18 | 19 | `teos` can be installed from PyPi bu running: 20 | 21 | ``` 22 | pip install python-teos 23 | ``` 24 | 25 | 26 | ## Modify Configuration Parameters 27 | If you'd like to modify some of the configuration defaults (such as the bitcoind rpcuser and password) you can do so in the config file located at: 28 | 29 | /.teos/teos.conf 30 | 31 | `` defaults to your home directory (`~`). 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Talaia Labs 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## State of the project 2 | ### This is not the codebase you're looking for. 3 | This codebase has been discontinued in favour of its rust counterpart. Please, do check [the new codebase](https://github.com/talaia-labs/rust-teos). 4 | 5 | # The Eye of Satoshi (TEOS) 6 | 7 | The Eye of Satoshi is a Lightning watchtower compliant with [BOLT13](https://github.com/sr-gi/bolt13), written in Python 3. 8 | 9 | `python-teos` consists in four main modules: 10 | 11 | - `teos`: including the tower's main functionality (server-side). 12 | - `cli`: including a reference command line interface. 13 | - `common`: including shared functionality between server and client side (useful to build a client). 14 | - `watchtower-plugin`: including a watchtower client plugin for c-lightning. 15 | 16 | Additionally `contrib` contains tools that are external to the tower (currently `teos_client`, an example Python client for the tower). 17 | 18 | Tests for every module can be found at `tests`. 19 | 20 | ## Dependencies 21 | 22 | Refer to [DEPENDENCIES.md](DEPENDENCIES.md) 23 | 24 | ## Installation 25 | 26 | Refer to [INSTALL.md](INSTALL.md) 27 | 28 | ## Running TEOS 29 | 30 | Make sure bitcoind is running before running TEOS (it will fail at startup if it cannot connect to bitcoind). You can find 31 | [here](DEPENDENCIES.md#installing-bitcoind) a sample config file. 32 | 33 | ### Starting the TEOS daemon 👁 34 | 35 | Once installed, you can start the tower by running: 36 | 37 | ``` 38 | teosd 39 | ``` 40 | 41 | ### Configuration file and command line parameters 42 | 43 | `teos` comes with a default configuration that can be found at [teos/\_\_init\_\_.py](teos/__init__.py). 44 | 45 | The configuration includes, amongst others, where your data folder is placed, what network it connects to, etc. 46 | 47 | To change the configuration defaults you can: 48 | 49 | - Define a configuration file named `teos.conf` following the template (check [teos/template.conf](teos/template.conf)) and place it in the `data_dir` (that defaults to `~/.teos/`) 50 | 51 | and / or 52 | 53 | - Add some global options when running the daemon (run `teosd -h` for more info). 54 | 55 | ### Passing command line options to `teosd` 56 | 57 | Some configuration options can also be passed as options when running `teosd`. We can, for instance, pick the network as follows: 58 | 59 | ``` 60 | teosd --btcnetwork=regtest 61 | ``` 62 | 63 | ### Running TEOS in another network 64 | 65 | By default, `teos` runs on `mainnet`. In order to run it on another network you need to change the network parameter in the configuration file or pass the network parameter as a command line option. Notice that if teos does not find a `bitcoind` node running in the same network that it is set to run, it will refuse to run. 66 | 67 | The configuration file option to change the network where `teos` will run is `btc_network` under the `bitcoind` section: 68 | 69 | ``` 70 | [bitcoind] 71 | btc_rpc_user = user 72 | btc_rpc_password = passwd 73 | btc_rpc_connect = localhost 74 | btc_network = mainnet 75 | ``` 76 | 77 | For regtest, it should look like: 78 | 79 | ``` 80 | [bitcoind] 81 | btc_rpc_user = user 82 | btc_rpc_password = passwd 83 | btc_rpc_connect = localhost 84 | btc_network = regtest 85 | ``` 86 | 87 | ## Running `teos` in a docker container 88 | A `teos` image can be built from the Dockerfile located in `docker`. You can create the image by running: 89 | 90 | cd python-teos 91 | docker build -f docker/Dockerfile -t teos . 92 | 93 | Then you can create a container by running: 94 | 95 | docker run -it -e ENVS teos 96 | 97 | Notice that ENV variables are optional, if unset the corresponding default setting is used. The following ENVs are available: 98 | 99 | ``` 100 | - API_BIND= 101 | - API_PORT= 102 | - RPC_BIND= 103 | - RPC_PORT= 104 | - BTC_NETWORK= 105 | - BTC_RPC_CONNECT= 106 | - BTC_RPC_PORT= 107 | - BTC_RPC_USER= 108 | - BTC_RPC_PASSWORD= 109 | - BTC_FEED_CONNECT= 110 | - BTC_FEED_PORT= 111 | ``` 112 | 113 | You may also want to run docker with a volume, so you can have data persistence in `teos` databases and keys. 114 | If so, run: 115 | 116 | docker volume create teos-data 117 | 118 | And add the the mount parameter to `docker run`: 119 | 120 | --mount source=teos-data,target=/root/.teos 121 | 122 | If you are running `teos` and `bitcoind` in the same machine, continue reading for how to create the container based on your OS. 123 | 124 | ### `bitcoind` running on the same machine (UNIX) 125 | The easiest way to run both together in he same machine using UNIX is to set the container to use the host network. 126 | 127 | For example, if both `teos` and `bitcoind` are running on default settings, run 128 | 129 | ``` 130 | docker run --network=host \ 131 | --name teos \ 132 | --mount source=teos-data,target=/root/.teos \ 133 | -e BTC_RPC_USER= \ 134 | -e BTC_RPC_PASSWD= \ 135 | -it teos 136 | ``` 137 | 138 | Notice that you may still need to set your RPC authentication details, since, hopefully, your credentials won't match the `teos` defaults. 139 | 140 | ### `bitcoind` running on the same machine (OSX or Windows) 141 | 142 | Docker for OSX and Windows does not allow to use the host network (nor to use the `docker0` bridge interface). To workaround this 143 | you can use the special `host.docker.internal` domain. 144 | 145 | ``` 146 | docker run -p 9814:9814 \ 147 | --name teos \ 148 | --mount source=teos-data,target=/root/.teos \ 149 | -e BTC_RPC_CONNECT=host.docker.internal \ 150 | -e BTC_FEED_CONNECT=host.docker.internal \ 151 | -e BTC_RPC_USER= \ 152 | -e BTC_RPC_PASSWD= \ 153 | -e API_BIND=0.0.0.0 \ 154 | -it teos 155 | ``` 156 | 157 | Notice that we also needed to add `API_BIND=0.0.0.0` to bind the API to all interfaces of the container. 158 | Otherwise it will bind to `localost` and we won't be able to send requests to the tower from the host. 159 | 160 | ### Tower id and signing key 161 | 162 | `teos` needs a pair of keys that will serve as tower id and signing key. The former can be used by users to identify the tower, whereas the latter is used by the tower to sign responses. These keys are automatically generated on the first run, and can be refreshed by running `teos` with the `--overwritekey` flag. 163 | 164 | 165 | ## Interacting with a TEOS Instance 166 | 167 | You can interact with a `teos` instance (either run by yourself or someone else) by using `teos-cli` under `teos/cli`. This is an admin tool that has privileged access to the watchtower, and it should therefore only be used within a trusted environment (for example, the same machine). 168 | 169 | While `teos-cli` works independently of `teos`, it shares the same configuration file by default, of which it only uses a subset of its settings. The folder can be changed using the `--datadir` command line argument, if desired. 170 | 171 | For help on the available arguments and commands, you can run: 172 | 173 | ``` 174 | teos-cli -h 175 | ``` 176 | 177 | ## Interacting with TEOS as a client 178 | 179 | The [contrib/client](contrib/client) folder contains an example Python client that can interact with the watchtower in order to register, add appointments and later retrieve them. 180 | 181 | See [here](contrib/client) for more information on how to use the client. 182 | 183 | Note that while the client is a simple way to interact with `teos`, ideally its functionality should be part of your wallet or lightning node. `teos_client` can be used as an example for how to send data to a [BOLT13](https://github.com/sr-gi/bolt13) compliant watchtower. 184 | 185 | ## Contributing 186 | Refer to [CONTRIBUTING.md](CONTRIBUTING.md) 187 | -------------------------------------------------------------------------------- /common/README.md: -------------------------------------------------------------------------------- 1 | # teos-common 2 | 3 | This is a common library for The Eye of Satoshi. It contains classes and methods that are shared by both the tower and 4 | the clients. -------------------------------------------------------------------------------- /common/__init__.py: -------------------------------------------------------------------------------- 1 | version_info = (0, 1, 1) 2 | __version__ = ".".join([str(v) for v in version_info]) 3 | -------------------------------------------------------------------------------- /common/appointment.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class AppointmentStatus(str, Enum): 5 | """Represents all the possible states of an appointment in the system, or in a response to a client request.""" 6 | 7 | NOT_FOUND = "not_found" 8 | BEING_WATCHED = "being_watched" 9 | DISPUTE_RESPONDED = "dispute_responded" 10 | 11 | 12 | class Appointment: 13 | """ 14 | The :class:`Appointment` contains the information regarding an appointment between a client and the Watchtower. 15 | 16 | Args: 17 | locator (:obj:`str`): A 16-byte hex-encoded value used by the tower to detect channel breaches. It serves as a 18 | trigger for the tower to decrypt and broadcast the penalty transaction. 19 | encrypted_blob (:obj:`str`): An encrypted blob of data containing a penalty transaction. The tower will decrypt 20 | it and broadcast the penalty transaction upon seeing a breach on the blockchain. 21 | to_self_delay (:obj:`int`): The ``to_self_delay`` encoded in the ``csv`` of the ``to_remote`` output of the 22 | commitment transaction that this appointment is covering. 23 | """ 24 | 25 | def __init__(self, locator, encrypted_blob, to_self_delay): 26 | self.locator = locator 27 | self.encrypted_blob = encrypted_blob 28 | self.to_self_delay = to_self_delay 29 | 30 | @classmethod 31 | def from_dict(cls, appointment_data): 32 | """ 33 | Builds an appointment from a dictionary. 34 | 35 | Args: 36 | appointment_data (:obj:`dict`): a dictionary containing the following keys: 37 | ``{locator, to_self_delay, encrypted_blob}`` 38 | 39 | Returns: 40 | :obj:`Appointment`: An appointment initialized using the provided data. 41 | 42 | Raises: 43 | ValueError: If one of the mandatory keys is missing in ``appointment_data``. 44 | """ 45 | 46 | locator = appointment_data.get("locator") 47 | encrypted_blob = appointment_data.get("encrypted_blob") 48 | to_self_delay = appointment_data.get("to_self_delay") 49 | 50 | if any(v is None for v in [locator, to_self_delay, encrypted_blob]): 51 | raise ValueError("Wrong appointment data, some fields are missing") 52 | 53 | return cls(locator, encrypted_blob, to_self_delay) 54 | 55 | def to_dict(self): 56 | """ 57 | Encodes an appointment as a dictionary. 58 | 59 | Returns: 60 | :obj:`dict`: A dictionary containing the appointment attributes. 61 | """ 62 | 63 | return self.__dict__ 64 | 65 | def serialize(self): 66 | """ 67 | Serializes an appointment to be signed. 68 | 69 | The serialization follows the same ordering as the fields in the appointment: 70 | 71 | locator | encrypted_blob | to_self_delay 72 | 73 | All values are big endian. 74 | 75 | Returns: 76 | :obj:`bytes`: The serialized data to be signed. 77 | """ 78 | return bytes.fromhex(self.locator) + bytes.fromhex(self.encrypted_blob) + self.to_self_delay.to_bytes(4, "big") 79 | -------------------------------------------------------------------------------- /common/config_loader.py: -------------------------------------------------------------------------------- 1 | import os 2 | import configparser 3 | 4 | 5 | class UnknownConfigParam(ValueError): 6 | """An exception for unknown configuration parameters found in config files""" 7 | 8 | pass 9 | 10 | 11 | class ConfigLoader: 12 | """ 13 | The :class:`ConfigLoader` class is in charge of loading all the configuration parameters to create a config dict 14 | that can be used to set all configurable parameters of the system. 15 | 16 | Args: 17 | data_dir (:obj:`str`): the path to the data directory where the configuration file may be found. 18 | default_conf (:obj:`dict`): a dictionary populated with the default configuration params and the expected types. 19 | The format is as follows: 20 | 21 | ``{"field0": {"value": value_from_conf_file, "type": expected_type, ...}}`` 22 | 23 | command_line_conf (:obj:`dict`): a dictionary containing the command line parameters that may replace the 24 | ones in default / config file. 25 | 26 | Attributes: 27 | data_dir (:obj:`str`): The path to the data directory where the configuration file may be found. 28 | conf_file_path (:obj:`str`): The path to the config file (the file may not exist). 29 | conf_fields (:obj:`dict`): A dictionary populated with the configuration params and the expected types. 30 | It follows the same format as ``default_conf``. 31 | command_line_conf (:obj:`dict`): A dictionary containing the command line parameters that may replace the 32 | ones in default / config file. 33 | """ 34 | 35 | def __init__(self, data_dir, conf_file_name, default_conf, command_line_conf): 36 | self.data_dir = data_dir 37 | self.conf_file_path = os.path.join(self.data_dir, conf_file_name) 38 | self.conf_fields = default_conf 39 | self.command_line_conf = command_line_conf 40 | self.overwritten_fields = set() 41 | 42 | def build_config(self): 43 | """ 44 | Builds a config dictionary from command line, config file and default configuration parameters. 45 | 46 | The priority is as follows: 47 | - command line 48 | - config file 49 | - defaults 50 | 51 | Returns: 52 | :obj:`dict`: A dictionary containing all the configuration parameters. 53 | 54 | Raises: 55 | UnknownConfigParam: if an unknown configuration value if found in the configuration file. 56 | """ 57 | 58 | if os.path.exists(self.conf_file_path): 59 | file_config = configparser.ConfigParser() 60 | file_config.read(self.conf_file_path) 61 | 62 | # Load parameters and cast them to int if necessary 63 | if file_config: 64 | for sec in file_config.sections(): 65 | for k, v in file_config.items(sec): 66 | k_upper = k.upper() 67 | if k_upper in self.conf_fields: 68 | if self.conf_fields[k_upper]["type"] == int: 69 | try: 70 | self.conf_fields[k_upper]["value"] = int(v) 71 | except ValueError: 72 | err_msg = "{} is not an integer ({}).".format(k, v) 73 | raise ValueError(err_msg) 74 | else: 75 | self.conf_fields[k_upper]["value"] = v 76 | 77 | self.overwritten_fields.add(k_upper) 78 | else: 79 | raise UnknownConfigParam(f"Unknown configuration value {k}") 80 | 81 | # Override the command line parameters to the defaults / conf file 82 | for k, v in self.command_line_conf.items(): 83 | self.conf_fields[k]["value"] = v 84 | self.overwritten_fields.add(k) 85 | 86 | # Extend relative paths 87 | expanded_data_dir = self.extend_paths() 88 | 89 | # Sanity check fields and build config dictionary 90 | config = self.create_config_dict() 91 | config["DATA_DIR"] = expanded_data_dir 92 | 93 | return config 94 | 95 | def create_config_dict(self): 96 | """ 97 | Checks that the configuration fields (``self.conf_fields``) have the right type and creates a config dict if so. 98 | 99 | Returns: 100 | :obj:`dict`: A dictionary with the same keys as the provided one, but containing only the "value" field as 101 | value if the provided ``conf_fields`` are correct. 102 | 103 | Raises: 104 | ValueError: If any of the dictionary elements does not have the expected type. 105 | """ 106 | 107 | conf_dict = {} 108 | 109 | for field in self.conf_fields: 110 | value = self.conf_fields[field]["value"] 111 | correct_type = self.conf_fields[field]["type"] 112 | 113 | if isinstance(value, correct_type): 114 | conf_dict[field] = value 115 | else: 116 | err_msg = "{} variable in config is of the wrong type".format(field) 117 | raise ValueError(err_msg) 118 | 119 | return conf_dict 120 | 121 | def extend_paths(self): 122 | """ 123 | Extends the relative paths of the ``conf_fields`` dictionary with ``data_dir``. 124 | 125 | If an absolute path is given, it will remain the same. 126 | 127 | If the config contains a BTC_NETWORK field whose value is not "mainnet", it is also appended to the path. 128 | """ 129 | 130 | # if there is a BTC_NETWORK parameter whose value is not "mainnet", we append it to the data_dir when expanding 131 | network_field = self.conf_fields.get("BTC_NETWORK") 132 | if network_field and network_field.get("value") != "mainnet": 133 | expanded_data_dir = os.path.join(self.data_dir, network_field.get("value")) 134 | else: 135 | expanded_data_dir = self.data_dir 136 | 137 | for key, field in self.conf_fields.items(): 138 | if field.get("path") and isinstance(field.get("value"), str): 139 | self.conf_fields[key]["value"] = os.path.join(expanded_data_dir, self.conf_fields[key]["value"]) 140 | 141 | return expanded_data_dir 142 | -------------------------------------------------------------------------------- /common/constants.py: -------------------------------------------------------------------------------- 1 | # Locator 2 | LOCATOR_LEN_HEX = 32 3 | LOCATOR_LEN_BYTES = LOCATOR_LEN_HEX // 2 4 | 5 | # HTTP 6 | HTTP_OK = 200 7 | HTTP_BAD_REQUEST = 400 8 | HTTP_NOT_FOUND = 404 9 | HTTP_SERVICE_UNAVAILABLE = 503 10 | 11 | # Bitcoin 12 | MAINNET_RPC_PORT = 8332 13 | TESTNET_RPC_PORT = 18332 14 | REGTEST_RPC_PORT = 18443 15 | 16 | # LN general nomenclature 17 | IRREVOCABLY_RESOLVED = 100 18 | 19 | # Temporary constants, may be changed 20 | ENCRYPTED_BLOB_MAX_SIZE_HEX = 2 * 2048 21 | -------------------------------------------------------------------------------- /common/db_manager.py: -------------------------------------------------------------------------------- 1 | import time 2 | import plyvel 3 | 4 | 5 | class DBManager: 6 | """ 7 | The :class:`DBManager` is in charge of interacting with a database (``LevelDB``). 8 | Keys and values are stored as bytes in the database but processed as strings by the manager. 9 | 10 | Args: 11 | db_path (:obj:`str`): the path (relative or absolute) to the system folder containing the database. A fresh 12 | database will be create if the specified path does not contain one. 13 | 14 | Raises: 15 | :obj:`ValueError`: if the provided ``db_path`` is not a string. 16 | :obj:`plyvel.Error`: if the db is currently unavailable (being used by another process). 17 | """ 18 | 19 | def __init__(self, db_path): 20 | if not isinstance(db_path, str): 21 | raise ValueError("db_path must be a valid path/name") 22 | 23 | self.db = plyvel.DB(db_path, create_if_missing=True) 24 | 25 | def close(self): 26 | """Closes the database and waits until it's done""" 27 | self.db.close() 28 | while not self.db.closed: 29 | time.sleep(1) 30 | 31 | def create_entry(self, key, value, prefix=None): 32 | """ 33 | Creates a new entry in the database. 34 | 35 | Args: 36 | key (:obj:`str`): the key of the new entry, used to identify it. 37 | value (:obj:`str`): the data stored under the given ``key``. 38 | prefix (:obj:`str`): an optional prefix added to the ``key``. 39 | 40 | Raises: 41 | :obj:`TypeError`: if key, value or prefix are not strings. 42 | """ 43 | 44 | if not isinstance(key, str): 45 | raise TypeError("Key must be str") 46 | 47 | if not isinstance(value, str): 48 | raise TypeError("Value must be str") 49 | 50 | if not isinstance(prefix, str) and prefix is not None: 51 | raise TypeError("Prefix (if set) must be str") 52 | 53 | if isinstance(prefix, str): 54 | key = prefix + key 55 | 56 | key = key.encode("utf-8") 57 | value = value.encode("utf-8") 58 | 59 | self.db.put(key, value) 60 | 61 | def load_entry(self, key, prefix=None): 62 | """ 63 | Loads an entry from the database given a ``key`` (and optionally a ``prefix``). 64 | 65 | Args: 66 | key (:obj:`str`): the key that identifies the entry to be loaded. 67 | prefix (:obj:`str`): an optional prefix added to the ``key``. 68 | 69 | Returns: 70 | :obj:`bytes` or :obj:`None`: A byte-array containing the requested data. 71 | 72 | Returns :obj:`None` if the entry is not found. 73 | 74 | Raises: 75 | :obj:`TypeError`: if key or prefix are not strings. 76 | """ 77 | 78 | if not isinstance(key, str): 79 | raise TypeError("Key must be str") 80 | 81 | if not isinstance(prefix, str) and prefix is not None: 82 | raise TypeError("Prefix (if set) must be str") 83 | 84 | if isinstance(prefix, str): 85 | key = prefix + key 86 | 87 | return self.db.get(key.encode("utf-8")) 88 | 89 | def delete_entry(self, key, prefix=None): 90 | """ 91 | Deletes an entry from the database given an ``key`` (and optionally a ``prefix``). 92 | 93 | Args: 94 | key (:obj:`str`): the key that identifies the data to be deleted. 95 | prefix (:obj:`str`): an optional prefix to be prepended to the ``key``. 96 | 97 | Raises: 98 | :obj:`TypeError`: if key or prefix are not strings. 99 | """ 100 | 101 | if not isinstance(key, str): 102 | raise TypeError("Key must be str") 103 | 104 | if not isinstance(prefix, str) and prefix is not None: 105 | raise TypeError("Prefix (if set) must be str") 106 | 107 | if isinstance(prefix, str): 108 | key = prefix + key 109 | 110 | key = key.encode("utf-8") 111 | 112 | self.db.delete(key) 113 | -------------------------------------------------------------------------------- /common/errors.py: -------------------------------------------------------------------------------- 1 | # Appointment errors [-1, -32] 2 | APPOINTMENT_EMPTY_FIELD = -1 3 | APPOINTMENT_WRONG_FIELD_TYPE = -2 4 | APPOINTMENT_WRONG_FIELD_SIZE = -3 5 | APPOINTMENT_WRONG_FIELD_FORMAT = -4 6 | APPOINTMENT_FIELD_TOO_SMALL = -5 7 | APPOINTMENT_FIELD_TOO_BIG = -6 8 | APPOINTMENT_WRONG_FIELD = -7 9 | APPOINTMENT_INVALID_SIGNATURE_OR_SUBSCRIPTION_ERROR = -8 10 | APPOINTMENT_ALREADY_TRIGGERED = -9 11 | 12 | # Registration errors [-33, -64] 13 | REGISTRATION_MISSING_FIELD = -33 14 | REGISTRATION_WRONG_FIELD_FORMAT = -34 15 | 16 | # General errors [-65, -96] 17 | INVALID_REQUEST_FORMAT = -65 18 | 19 | # Custom RPC errors [255+] 20 | RPC_TX_REORGED_AFTER_BROADCAST = -256 21 | # UNHANDLED 22 | UNKNOWN_JSON_RPC_EXCEPTION = -257 23 | -------------------------------------------------------------------------------- /common/exceptions.py: -------------------------------------------------------------------------------- 1 | class BasicException(Exception): 2 | """Base exception for all the other custom ones. Allows to store a message and some ``kwargs``.""" 3 | 4 | def __init__(self, msg, **kwargs): 5 | self.msg = msg 6 | self.kwargs = kwargs 7 | 8 | def __str__(self): 9 | if len(self.kwargs) > 2: 10 | params = "".join("{}={}, ".format(k, v) for k, v in self.kwargs.items()) 11 | 12 | # Remove the extra 2 characters (space and comma) and add all data to the final message. 13 | message = self.msg + " ({})".format(params[:-2]) 14 | 15 | else: 16 | message = self.msg 17 | 18 | return message 19 | 20 | def to_json(self): 21 | return {"error": self.msg, **self.kwargs} 22 | 23 | 24 | class InvalidParameter(BasicException): 25 | """Raised when a command line parameter is invalid (either missing or wrong).""" 26 | 27 | 28 | class InvalidKey(BasicException): 29 | """Raised when there is an error loading the keys.""" 30 | 31 | 32 | class EncryptionError(BasicException): 33 | """Raised when there is an error with encryption related functions, covers decryption.""" 34 | 35 | 36 | class SignatureError(BasicException): 37 | """Raised when there is an with the signature related functions, covers EC recover.""" 38 | 39 | 40 | class TowerConnectionError(BasicException): 41 | """Raised when the tower responds with an error.""" 42 | 43 | 44 | class TowerResponseError(BasicException): 45 | """Raised when the tower responds with an error.""" 46 | -------------------------------------------------------------------------------- /common/receipts.py: -------------------------------------------------------------------------------- 1 | import struct 2 | import pyzbase32 3 | 4 | from common.tools import is_compressed_pk, is_u4int 5 | from common.exceptions import InvalidParameter 6 | 7 | 8 | def create_registration_receipt(user_id, available_slots, subscription_expiry): 9 | """ 10 | Creates a registration receipt. 11 | 12 | The receipt has the following format: 13 | 14 | ``user_id (33-byte) | available_slots (4-byte) | subscription_expiry (4-byte)`` 15 | 16 | All values are big endian. 17 | 18 | Args: 19 | user_id(:obj:`str`): the public key that identifies the user (33-bytes hex str). 20 | available_slots (:obj:`int`): the number of slots assigned to a user subscription (4-byte unsigned int). 21 | subscription_expiry (:obj:`int`): the expiry assigned to a user subscription (4-byte unsigned int). 22 | 23 | Returns: 24 | :obj:`bytes`: The serialized data to be signed. 25 | """ 26 | 27 | if not is_compressed_pk(user_id): 28 | raise InvalidParameter("Provided public key does not match expected format (33-byte hex string)") 29 | elif not is_u4int(available_slots): 30 | raise InvalidParameter("Provided available_slots must be a 4-byte unsigned integer") 31 | elif not is_u4int(subscription_expiry): 32 | raise InvalidParameter("Provided subscription_expiry must be a 4-byte unsigned integer") 33 | 34 | return bytes.fromhex(user_id) + available_slots.to_bytes(4, "big") + subscription_expiry.to_bytes(4, "big") 35 | 36 | 37 | def create_appointment_receipt(user_signature, start_block): 38 | """ 39 | Creates an appointment receipt. 40 | 41 | The receipt has the following format: 42 | 43 | ``user_signature | start_block (4-byte)`` 44 | 45 | All values are big endian. 46 | 47 | Args: 48 | user_signature (:obj:`str`): the signature of the appointment by the user. 49 | start_block (:obj:`int`): the block height at which the tower will start watching for the appointment. 50 | 51 | Returns: 52 | :obj:`bytes`: The serialized data to be signed. 53 | """ 54 | 55 | if not isinstance(user_signature, str): 56 | raise InvalidParameter("Provided user_signature is invalid") 57 | elif not is_u4int(start_block): 58 | raise InvalidParameter("Provided start_block must be a 4-byte unsigned integer") 59 | 60 | return pyzbase32.decode_bytes(user_signature) + struct.pack(">I", start_block) 61 | -------------------------------------------------------------------------------- /common/requirements.txt: -------------------------------------------------------------------------------- 1 | wheel 2 | cryptography>=2.8 3 | coincurve 4 | pyzbase32 5 | plyvel 6 | -------------------------------------------------------------------------------- /common/tools.py: -------------------------------------------------------------------------------- 1 | import re 2 | from pathlib import Path 3 | from common.constants import LOCATOR_LEN_HEX 4 | 5 | 6 | def is_compressed_pk(value): 7 | """ 8 | Checks if a given value is a 33-byte hex-encoded string starting by 02 or 03. 9 | 10 | Args: 11 | value(:obj:`str`): the value to be checked. 12 | 13 | Returns: 14 | :obj:`bool`: Whether or not the value matches the format. 15 | """ 16 | 17 | return isinstance(value, str) and re.match(r"^0[2-3][0-9A-Fa-f]{64}$", value) is not None 18 | 19 | 20 | def is_256b_hex_str(value): 21 | """ 22 | Checks if a given value is a 32-byte hex encoded string. 23 | 24 | Args: 25 | value(:mod:`str`): the value to be checked. 26 | 27 | Returns: 28 | :obj:`bool`: Whether or not the value matches the format. 29 | """ 30 | return isinstance(value, str) and re.match(r"^[0-9A-Fa-f]{64}$", value) is not None 31 | 32 | 33 | def is_u4int(value): 34 | """ 35 | Checks if a given value is an unsigned 4-byte integer. 36 | 37 | Args: 38 | value(:mod:`int`): the value to be checked. 39 | 40 | Returns: 41 | :obj:`bool`: Whether or not the value matches the format. 42 | """ 43 | return isinstance(value, int) and 0 <= value <= pow(2, 32) - 1 44 | 45 | 46 | def is_locator(value): 47 | """ 48 | Checks if a given value is a 16-byte hex encoded string. 49 | 50 | Args: 51 | value(:mod:`str`): the value to be checked. 52 | 53 | Returns: 54 | :obj:`bool`: Whether or not the value matches the format. 55 | """ 56 | return isinstance(value, str) and re.match(r"^[0-9A-Fa-f]{32}$", value) is not None 57 | 58 | 59 | def compute_locator(tx_id): 60 | """ 61 | Computes an appointment locator given a transaction id. 62 | 63 | Args: 64 | tx_id (:obj:`str`): the transaction id used to compute the locator. 65 | 66 | Returns: 67 | :obj:`str`: The computed locator. 68 | """ 69 | 70 | return tx_id[:LOCATOR_LEN_HEX] 71 | 72 | 73 | def setup_data_folder(data_folder): 74 | """ 75 | Create a data folder for either the client or the server side if the folder does not exists. 76 | 77 | Args: 78 | data_folder (:obj:`str`): the path of the folder. 79 | """ 80 | 81 | Path(data_folder).mkdir(parents=True, exist_ok=True) 82 | 83 | 84 | def intify(obj): 85 | """ 86 | Takes an object that is a recursive composition of primitive types, lists and dictionaries, and returns an 87 | equivalent object where every `float` number that is actually an integer is replaced with the corresponding 88 | :obj:`int`. 89 | 90 | Args: 91 | obj: an object as specified. 92 | 93 | Returns: 94 | The modified version of ``obj``. 95 | """ 96 | 97 | if isinstance(obj, list): 98 | return [intify(x) for x in obj] 99 | elif isinstance(obj, dict): 100 | return {k: intify(v) for k, v in obj.items()} 101 | elif isinstance(obj, float) and obj.is_integer(): 102 | return int(obj) 103 | else: 104 | return obj 105 | -------------------------------------------------------------------------------- /contrib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/talaia-labs/python-teos/10d82c8588e4461a6db19bd27920914fc144fdd7/contrib/__init__.py -------------------------------------------------------------------------------- /contrib/client/DEPENDENCIES.md: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | 3 | `teos-client` has both system-wide and Python dependencies. This document walks you through how to satisfy them. 4 | 5 | ## System-wide dependencies 6 | 7 | `teos-client` has the following system-wide dependencies: 8 | 9 | - `python3` version 3.7+ 10 | - `pip3` 11 | - `openssl` version 1.1+ 12 | 13 | ### Checking if the dependencies are already satisfied 14 | 15 | Most UNIX systems ship with `python3` already installed, whereas OSX systems tend to ship with `python2`. In order to check our python version we should run: 16 | 17 | python --version 18 | 19 | For what we will get something like: 20 | 21 | Python 2.X.X 22 | 23 | Or 24 | 25 | Python 3.X.X 26 | 27 | It is also likely that, if `python3` is installed in our system, the `python` alias is not set to it but instead to `python2`. In order to check so, we can run: 28 | 29 | python3 --version 30 | 31 | If `python3` is installed but the `python` alias is not set to it, we should either set it, or use `python3` to run `teos-client`. 32 | 33 | Regarding `pip`, we can check what version is installed in our system (if any) by running: 34 | 35 | pip --version 36 | 37 | For what we will get something like: 38 | 39 | pip X.X.X from /usr/local/lib/python2.X/dist-packages/pip (python 2.X) 40 | 41 | Or 42 | 43 | pip X.X.X from /usr/local/lib/python3.X/dist-packages/pip (python 3.X) 44 | 45 | A similar thing to the `python` alias applies to the `pip` alias. We can check if pip3 is install by running: 46 | 47 | pip3 --version 48 | 49 | And, if it happens to be installed, change the alias to `pip3`, or use `pip3` instead of `pip`. 50 | 51 | 52 | ### Installing the dependencies 53 | 54 | `python3` can be downloaded from the [Python official website](https://www.python.org/downloads/) or installed using a package manager, depending on your distribution. Examples for both UNIX-like and OSX systems are provided. 55 | 56 | #### Ubuntu 57 | 58 | `python3` can be installed using `apt` as follows: 59 | 60 | sudo apt-get update 61 | sudo apt-get install python3 62 | 63 | and for `pip3`: 64 | 65 | sudo apt-get install python3-pip 66 | pip install --upgrade pip==9.0.3 67 | 68 | #### OSX 69 | 70 | `python3` can be installed using `Homebrew` as follows: 71 | 72 | brew install python3 73 | 74 | `pip3` will be installed alongside `python3` in this case. 75 | 76 | ## Python Dependencies 77 | 78 | `teos-client` has several python dependencies that are automatically alongside the it. Should you need to install them manually, you can do so by running: 79 | 80 | ``` 81 | pip install -r requirements.txt` 82 | ``` -------------------------------------------------------------------------------- /contrib/client/INSTALL.md: -------------------------------------------------------------------------------- 1 | # Install 2 | 3 | `teos-client` gets installed alongside `teos` as long as you set the development flag when installing `teos` (from `python-teos/`): 4 | 5 | ``` 6 | DEV=1 pip install . 7 | ``` 8 | 9 | You can also get a standalone client from pip: 10 | 11 | ``` 12 | pip install teos-client 13 | ``` -------------------------------------------------------------------------------- /contrib/client/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | version_info = (0, 1, 1) 4 | __version__ = ".".join([str(v) for v in version_info]) 5 | 6 | DATA_DIR = os.path.expanduser("~/.teos_client/") 7 | CONF_FILE_NAME = "teos_client.conf" 8 | 9 | # Load config fields 10 | DEFAULT_CONF = { 11 | "API_CONNECT": {"value": "localhost", "type": str}, 12 | "API_PORT": {"value": 9814, "type": int}, 13 | "APPOINTMENTS_FOLDER_NAME": {"value": "appointment_receipts", "type": str, "path": True}, 14 | "USER_PRIVATE_KEY": {"value": "user_sk.der", "type": str, "path": True}, 15 | "TEOS_PUBLIC_KEY": {"value": "teos_pk.der", "type": str, "path": True}, 16 | } 17 | -------------------------------------------------------------------------------- /contrib/client/help.py: -------------------------------------------------------------------------------- 1 | def show_usage(): 2 | return ( 3 | "USAGE: " 4 | "\n\tteos-client [global options] command [command options] [arguments]" 5 | "\n\nCOMMANDS:" 6 | "\n\tregister \t\tRegisters your user public key with the tower." 7 | "\n\tadd_appointment \tRegisters a json formatted appointment with the tower." 8 | "\n\tget_appointment \tGets json formatted data about an appointment from the tower." 9 | "\n\tget_subscription_info \tGets json formatted data about a user's subscription from the tower." 10 | "\n\thelp \t\t\tShows a list of commands or help for a specific command." 11 | "\n\nGLOBAL OPTIONS:" 12 | "\n\t--apiconnect \tAPI server where to send the requests. Defaults to 'localhost' (modifiable in conf file)." 13 | "\n\t--apiport \tAPI port where to send the requests. Defaults to '9814' (modifiable in conf file)." 14 | "\n\t-d, --debug \tShows debug information." 15 | "\n\t-h, --help \tShows this message." 16 | ) 17 | 18 | 19 | def help_register(): 20 | return ( 21 | "NAME:" 22 | "\n\n\tregister" 23 | "\n\nUSAGE:" 24 | '\n\n\tteos-client register "tower_id"' 25 | "\n\nDESCRIPTION:" 26 | "\n\n\tRegisters your user public key with the tower." 27 | ) 28 | 29 | 30 | def help_add_appointment(): 31 | return ( 32 | "NAME:" 33 | "\n\tadd_appointment - Registers a json formatted appointment to the tower." 34 | "\n\nUSAGE:" 35 | '\n\tteos-client add_appointment "appointment"' 36 | '\n\tteos-client add_appointment -f "path_to_appointment_file"' 37 | "\n\nDESCRIPTION:" 38 | "\n\n\tRegisters a json-formatted appointment to the tower." 39 | "\n\tIf -f, --file *is* specified, then the command expects a path to a json file instead of a json encoded " 40 | "\n\tstring as parameter. Otherwise, the last argument is the appointment itself, encoded in json." 41 | ) 42 | 43 | 44 | def help_get_appointment(): 45 | return ( 46 | "NAME:" 47 | "\n\tget_appointment - Gets json formatted data about an appointment from the tower." 48 | "\n\nUSAGE:" 49 | '\n\tteos-client get_appointment "appointment_locator"' 50 | "\n\nDESCRIPTION:" 51 | "\n\n\tGets json formatted data about an appointment from the tower.\n" 52 | ) 53 | 54 | 55 | def help_get_subscription_info(): 56 | return ( 57 | "NAME:" 58 | "\n\tget_subscription_info - Gets json formatted data about a user's subscription from the tower." 59 | "\n\nUSAGE:" 60 | "\n\tteos-client get_subscription_info" 61 | "\n\nDESCRIPTION:" 62 | "\n\n\tGets json formatted data about a user's subscription from the tower.\n" 63 | ) 64 | -------------------------------------------------------------------------------- /contrib/client/requirements.txt: -------------------------------------------------------------------------------- 1 | cryptography>=2.8 2 | requests 3 | structlog 4 | -------------------------------------------------------------------------------- /contrib/client/template.conf: -------------------------------------------------------------------------------- 1 | [teos] 2 | api_connect = localhost 3 | api_port = 9814 4 | -------------------------------------------------------------------------------- /contrib/client/test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/talaia-labs/python-teos/10d82c8588e4461a6db19bd27920914fc144fdd7/contrib/client/test/__init__.py -------------------------------------------------------------------------------- /contrib/client/test/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import random 3 | 4 | from contrib.client import DEFAULT_CONF 5 | 6 | from common.config_loader import ConfigLoader 7 | 8 | 9 | @pytest.fixture(scope="session", autouse=True) 10 | def prng_seed(): 11 | random.seed(0) 12 | 13 | 14 | def get_random_value_hex(nbytes): 15 | pseudo_random_value = random.getrandbits(8 * nbytes) 16 | prv_hex = "{:x}".format(pseudo_random_value) 17 | return prv_hex.zfill(2 * nbytes) 18 | 19 | 20 | def get_config(): 21 | config_loader = ConfigLoader(".", "teos_client.conf", DEFAULT_CONF, {}) 22 | config = config_loader.build_config() 23 | 24 | return config 25 | -------------------------------------------------------------------------------- /contrib/tools/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/talaia-labs/python-teos/10d82c8588e4461a6db19bd27920914fc144fdd7/contrib/tools/__init__.py -------------------------------------------------------------------------------- /contrib/tools/fill_subscription.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import json 3 | from test.teos.conftest import get_random_value_hex 4 | from contrib.client.teos_client import main as teos_client 5 | 6 | appointment_data = { 7 | "tx": "4615a58815475ab8145b6bb90b1268a0dbb02e344ddd483f45052bec1f15b1951c1ee7f070a0993da395a5ee92ea3a1c184b5ffdb250" 8 | "7164bf1f8c1364155d48bdbc882eee0868ca69864a807f213f538990ad16f56d7dfb28a18e69e3f31ae9adad229e3244073b7d643b4597ec88" 9 | "bf247b9f73f301b0f25ae8207b02b7709c271da98af19f1db276ac48ba64f099644af1ae2c90edb7def5e8589a1bb17cc72ac42ecf07dd29cf" 10 | "f91823938fd0d772c2c92b7ab050f8837efd46197c9b2b3f", 11 | "tx_id": "0af510d92a50c1d67c6f7fc5d47908d96b3eccdea093d89bcbaf05bcfebdd982", 12 | "to_self_delay": 20, 13 | } 14 | 15 | # Add too many appointment by changing the tx_id (100 is the default max) 16 | for _ in range(int(sys.argv[1])): 17 | appointment_data["tx_id"] = get_random_value_hex(32) 18 | teos_client("add_appointment", [json.dumps(appointment_data)], {}) 19 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3 2 | VOLUME ["/root/.teos"] 3 | WORKDIR /srv 4 | ADD . /srv/python-teos 5 | RUN apt-get update && \ 6 | apt-get -y --no-install-recommends install libffi-dev libssl-dev pkg-config libleveldb-dev && \ 7 | apt-get clean && \ 8 | rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* 9 | RUN mkdir /root/.teos && cd python-teos && pip install . 10 | WORKDIR /srv/python-teos 11 | EXPOSE 9814/tcp 12 | ENTRYPOINT [ "/srv/python-teos/docker/entrypoint.sh" ] 13 | -------------------------------------------------------------------------------- /docker/arm32v7.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rycus86/armhf-debian-qemu 2 | VOLUME ["~/.teos"] 3 | WORKDIR /srv 4 | ADD . /srv/python-teos 5 | RUN apt-get update && apt-get -y install python3 python3-pip libffi-dev libssl-dev pkg-config libleveldb-dev libzmq3-dev 6 | RUN mkdir ~/.teos && cd python-teos && pip3 install . 7 | WORKDIR /srv/python-teos 8 | EXPOSE 9814/tcp 9 | ENTRYPOINT [ "/srv/python-teos/docker/entrypoint.sh" ] -------------------------------------------------------------------------------- /docker/arm64v8.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rycus86/arm64v8-debian-qemu 2 | VOLUME ["~/.teos"] 3 | WORKDIR /srv 4 | ADD . /srv/python-teos 5 | RUN apt-get update && apt-get -y install python3 python3-pip libffi-dev libssl-dev pkg-config libleveldb-dev libzmq3-dev 6 | RUN mkdir ~/.teos && cd python-teos && pip3 install . 7 | WORKDIR /srv/python-teos 8 | EXPOSE 9814/tcp 9 | ENTRYPOINT [ "/srv/python-teos/docker/entrypoint.sh" ] -------------------------------------------------------------------------------- /docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | START_COMMAND="teosd " 4 | 5 | if [[ ! -z ${API_BIND} ]]; then 6 | START_COMMAND=$START_COMMAND" --apibind=""$API_BIND" 7 | fi 8 | 9 | if [[ ! -z ${API_PORT} ]]; then 10 | START_COMMAND=$START_COMMAND" --apiport=""$API_PORT" 11 | fi 12 | 13 | if [[ ! -z ${RPC_BIND} ]]; then 14 | START_COMMAND=$START_COMMAND" --rpcbind=""$RPC_BIND" 15 | fi 16 | 17 | if [[ ! -z ${RPC_PORT} ]]; then 18 | START_COMMAND=$START_COMMAND" --rpcport=""$RPC_PORT" 19 | fi 20 | 21 | if [[ ! -z ${BTC_NETWORK} ]]; then 22 | START_COMMAND=$START_COMMAND" --btcnetwork=""$BTC_NETWORK" 23 | fi 24 | 25 | if [[ ! -z ${BTC_RPC_USER} ]]; then 26 | START_COMMAND=$START_COMMAND" --btcrpcuser=""$BTC_RPC_USER" 27 | fi 28 | 29 | if [[ ! -z ${BTC_RPC_PASSWORD} ]]; then 30 | START_COMMAND=$START_COMMAND" --btcrpcpassword=""$BTC_RPC_PASSWORD" 31 | fi 32 | 33 | if [[ ! -z ${BTC_RPC_CONNECT} ]]; then 34 | START_COMMAND=$START_COMMAND" --btcrpcconnect=""$BTC_RPC_CONNECT" 35 | fi 36 | 37 | if [[ ! -z ${BTC_RPC_PORT} ]]; then 38 | START_COMMAND=$START_COMMAND" --btcrpcport=""$BTC_RPC_PORT" 39 | fi 40 | 41 | if [[ ! -z ${BTC_FEED_CONNECT} ]]; then 42 | START_COMMAND=$START_COMMAND" --btcfeedconnect=""$BTC_FEED_CONNECT" 43 | fi 44 | 45 | if [[ ! -z ${BTC_FEED_PORT} ]]; then 46 | START_COMMAND=$START_COMMAND" --btcfeedport=""$BTC_FEED_PORT" 47 | fi 48 | 49 | $START_COMMAND 50 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-timeout 3 | black 4 | flake8 5 | responses 6 | riemann-tx 7 | grpcio-tools 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | wheel 2 | zmq 3 | flask 4 | grpcio 5 | protobuf>=3.12.0 6 | cryptography>=2.8 7 | coincurve 8 | pyzbase32 9 | requests 10 | plyvel 11 | readerwriterlock 12 | structlog 13 | python-daemon 14 | waitress>=2.0.0 15 | gunicorn; platform_system != "Windows" 16 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import glob 3 | import shutil 4 | import setuptools 5 | 6 | from teos import __version__ 7 | 8 | with open("README.md", "r") as fh: 9 | long_description = fh.read() 10 | 11 | 12 | # Remove undesired files 13 | wildcards = ["**/__pycache__", "**/.DS_Store"] 14 | for entry in wildcards: 15 | for file_dir in glob.glob(entry, recursive=True): 16 | if os.path.isdir(file_dir): 17 | shutil.rmtree(file_dir) 18 | elif os.path.isfile(file_dir): 19 | os.remove(file_dir) 20 | 21 | # Installing common library only 22 | if os.getenv("COMMON_ONLY", False): 23 | PACKAGES = ["common"] 24 | CONSOLE_SCRIPTS = [] 25 | 26 | with open("common/requirements.txt") as f: 27 | requirements = [r for r in f.read().split("\n") if len(r)] 28 | else: 29 | PACKAGES = ["common", "teos", "teos.cli", "teos.protobuf", "teos.utils"] 30 | CONSOLE_SCRIPTS = ["teosd=teos.teosd:run", "teos-cli=teos.cli.teos_cli:run"] 31 | 32 | with open("requirements.txt") as f: 33 | requirements = [r for r in f.read().split("\n") if len(r)] 34 | 35 | # Add additional scripts if DEV=1 36 | if os.getenv("DEV", False): 37 | # Add missing requirements 38 | with open("contrib/client/requirements.txt") as f: 39 | requirements_client = [r for r in f.read().split("\n") if len(r)] 40 | 41 | requirements = list(set(requirements).union(requirements_client)) 42 | 43 | # Extend packages 44 | PACKAGES.extend(["contrib", "contrib.client"]) 45 | 46 | # Add console scripts 47 | CONSOLE_SCRIPTS.append("teos-client=contrib.client.teos_client:run") 48 | 49 | CLASSIFIERS = [ 50 | "Programming Language :: Python", 51 | "Programming Language :: Python :: 3", 52 | "Programming Language :: Python :: 3.7", 53 | "Programming Language :: Python :: 3.8", 54 | "Programming Language :: Python :: 3 :: Only", 55 | "License :: OSI Approved :: MIT License", 56 | "Operating System :: OS Independent", 57 | "Topic :: Internet", 58 | "Topic :: Utilities", 59 | "Topic :: Software Development :: Libraries :: Python Modules", 60 | ] 61 | 62 | 63 | setuptools.setup( 64 | name="python-teos", 65 | version=__version__, 66 | author="Talaia Labs", 67 | author_email="contact@talaia.watch", 68 | description="The Eye of Satoshi - Lightning Watchtower", 69 | long_description=long_description, 70 | long_description_content_type="text/markdown", 71 | url="https://github.com/talaia-labs/python-teos", 72 | packages=setuptools.find_packages(include=PACKAGES), 73 | classifiers=CLASSIFIERS, 74 | python_requires=">=3.7", 75 | install_requires=requirements, 76 | entry_points={"console_scripts": CONSOLE_SCRIPTS}, 77 | ) 78 | -------------------------------------------------------------------------------- /teos/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from common.constants import MAINNET_RPC_PORT 3 | 4 | version_info = (0, 1, 1) 5 | __version__ = ".".join([str(v) for v in version_info]) 6 | 7 | DATA_DIR = os.path.expanduser("~/.teos/") 8 | CONF_FILE_NAME = "teos.conf" 9 | DEFAULT_CONF = { 10 | "API_BIND": {"value": "localhost", "type": str}, 11 | "API_PORT": {"value": 9814, "type": int}, 12 | "RPC_BIND": {"value": "localhost", "type": str}, 13 | "RPC_PORT": {"value": 8814, "type": int}, 14 | "BTC_RPC_USER": {"value": "user", "type": str}, 15 | "BTC_RPC_PASSWORD": {"value": "passwd", "type": str}, 16 | "BTC_RPC_CONNECT": {"value": "127.0.0.1", "type": str}, 17 | "BTC_RPC_PORT": {"value": MAINNET_RPC_PORT, "type": int}, 18 | "BTC_NETWORK": {"value": "mainnet", "type": str}, 19 | "BTC_FEED_PROTOCOL": {"value": "tcp", "type": str}, 20 | "BTC_FEED_CONNECT": {"value": "localhost", "type": str}, 21 | "BTC_FEED_PORT": {"value": 28332, "type": int}, 22 | "DAEMON": {"value": False, "type": bool}, 23 | "MAX_APPOINTMENTS": {"value": 1000000, "type": int}, 24 | "SUBSCRIPTION_SLOTS": {"value": 100, "type": int}, 25 | "SUBSCRIPTION_DURATION": {"value": 4320, "type": int}, 26 | "EXPIRY_DELTA": {"value": 6, "type": int}, 27 | "MIN_TO_SELF_DELAY": {"value": 20, "type": int}, 28 | "LOCATOR_CACHE_SIZE": {"value": 6, "type": int}, 29 | "OVERWRITE_KEY": {"value": False, "type": bool}, 30 | "WSGI": {"value": "gunicorn", "type": str}, 31 | "LOG_FILE": {"value": "teos.log", "type": str, "path": True}, 32 | "TEOS_SECRET_KEY": {"value": "teos_sk.der", "type": str, "path": True}, 33 | "APPOINTMENTS_DB_PATH": {"value": "appointments", "type": str, "path": True}, 34 | "USERS_DB_PATH": {"value": "users", "type": str, "path": True}, 35 | "INTERNAL_API_HOST": {"value": "localhost", "type": str}, 36 | "INTERNAL_API_PORT": {"value": 50051, "type": int}, 37 | "INTERNAL_API_WORKERS": {"value": 10, "type": int}, 38 | } 39 | -------------------------------------------------------------------------------- /teos/builder.py: -------------------------------------------------------------------------------- 1 | from teos.responder import TransactionTracker 2 | from teos.extended_appointment import ExtendedAppointment 3 | 4 | 5 | class Builder: 6 | """ 7 | The :class:`Builder` class is in charge of reconstructing data loaded from the appointments database and build the 8 | data structures of the :obj:`Watcher ` and the :obj:`Responder `. 9 | """ 10 | 11 | @staticmethod 12 | def build_appointments(appointments_data): 13 | """ 14 | Builds an appointments dictionary (``uuid:extended_appointment``) and a locator_uuid_map (``locator:uuid``) 15 | given a dictionary of appointments from the database. 16 | 17 | Args: 18 | appointments_data (:obj:`dict`): a dictionary of dictionaries representing all the 19 | :obj:`Watcher ` appointments stored in the database. The structure is as follows: 20 | 21 | ``{uuid: {locator: str, ...}, uuid: {locator:...}}`` 22 | 23 | Returns: 24 | :obj:`tuple`: A tuple with two dictionaries. ``appointments`` containing the appointment information in 25 | :obj:`ExtendedAppointment ` objects and ``locator_uuid_map`` 26 | containing a map of appointment (``uuid:locator``). 27 | """ 28 | 29 | appointments = {} 30 | locator_uuid_map = {} 31 | 32 | for uuid, data in appointments_data.items(): 33 | ext_appointment = ExtendedAppointment.from_dict(data) 34 | appointments[uuid] = ext_appointment.get_summary() 35 | 36 | if ext_appointment.locator in locator_uuid_map: 37 | locator_uuid_map[ext_appointment.locator].append(uuid) 38 | 39 | else: 40 | locator_uuid_map[ext_appointment.locator] = [uuid] 41 | 42 | return appointments, locator_uuid_map 43 | 44 | @staticmethod 45 | def build_trackers(tracker_data): 46 | """ 47 | Builds a tracker dictionary (``uuid:TransactionTracker``) and a tx_tracker_map (``penalty_txid:uuid``) given 48 | a dictionary of trackers from the database. 49 | 50 | Args: 51 | tracker_data (:obj:`dict`): a dictionary of dictionaries representing all the 52 | :mod:`Responder ` trackers stored in the database. 53 | The structure is as follows: 54 | 55 | ``{uuid: {locator: str, dispute_txid: str, ...}, uuid: {locator:...}}`` 56 | 57 | Returns: 58 | :obj:`tuple`: A tuple with two dictionaries. ``trackers`` containing the trackers' information in 59 | :obj:`TransactionTracker ` objects and a ``tx_tracker_map`` containing 60 | the map of trackers (``penalty_txid: uuid``). 61 | 62 | """ 63 | 64 | trackers = {} 65 | tx_tracker_map = {} 66 | 67 | for uuid, data in tracker_data.items(): 68 | tracker = TransactionTracker.from_dict(data) 69 | trackers[uuid] = tracker.get_summary() 70 | 71 | if tracker.penalty_txid in tx_tracker_map: 72 | tx_tracker_map[tracker.penalty_txid].append(uuid) 73 | 74 | else: 75 | tx_tracker_map[tracker.penalty_txid] = [uuid] 76 | 77 | return trackers, tx_tracker_map 78 | 79 | @staticmethod 80 | def populate_block_queue(block_queue, missed_blocks): 81 | """ 82 | Populates a ``Queue`` of block hashes to initialize the :mod:`Watcher ` or the 83 | :mod:`Responder ` using backed up data. 84 | 85 | Args: 86 | block_queue (:obj:`Queue`): a queue. 87 | missed_blocks (:obj:`list`): list of block hashes missed by the Watchtower (due to a crash or shutdown). 88 | 89 | Returns: 90 | :obj:`Queue`: A queue containing all the missed blocks hashes. 91 | """ 92 | 93 | for block in missed_blocks: 94 | block_queue.put(block) 95 | 96 | @staticmethod 97 | def update_states(watcher_queue, responder_queue, missed_blocks_watcher, missed_blocks_responder): 98 | """ 99 | Updates the states of both the :mod:`Watcher ` and the 100 | :mod:`Responder `. If both have pending blocks to process they need to be updated at 101 | the same time, block by block. 102 | 103 | If only one instance has to be updated, ``populate_block_queue`` should be used. 104 | 105 | Args: 106 | watcher_queue (:obj:`Queue`): the :obj:`Watcher`'s block queue. 107 | responder_queue (:obj:`Queue`): the :obj:`Responder`'s block queue. 108 | missed_blocks_watcher (:obj:`list`): the list of block missed by the :obj:`Watcher`. 109 | missed_blocks_responder (:obj:`list`): the list of block missed by the :obj:`Responder`. 110 | 111 | Raises: 112 | ValueError: if one of the provided list is empty. 113 | """ 114 | 115 | if len(missed_blocks_responder) == 0 or len(missed_blocks_watcher) == 0: 116 | raise ValueError( 117 | "Both the Watcher and the Responder must have missed blocks. Use ``populate_block_queue`` otherwise." 118 | ) 119 | 120 | # If the missed blocks of the Watcher and the Responder are not the same, we need to bring one up to date with 121 | # the other. 122 | if len(missed_blocks_responder) > len(missed_blocks_watcher): 123 | block_diff = sorted( 124 | set(missed_blocks_responder).difference(missed_blocks_watcher), key=missed_blocks_responder.index 125 | ) 126 | Builder.populate_block_queue(responder_queue, block_diff) 127 | responder_queue.join() 128 | 129 | elif len(missed_blocks_watcher) > len(missed_blocks_responder): 130 | block_diff = sorted( 131 | set(missed_blocks_watcher).difference(missed_blocks_responder), key=missed_blocks_watcher.index 132 | ) 133 | Builder.populate_block_queue(watcher_queue, block_diff) 134 | watcher_queue.join() 135 | 136 | # Once they are at the same height, we update them one by one 137 | for block in missed_blocks_watcher: 138 | watcher_queue.put(block) 139 | watcher_queue.join() 140 | 141 | responder_queue.put(block) 142 | responder_queue.join() 143 | -------------------------------------------------------------------------------- /teos/cli/README.md: -------------------------------------------------------------------------------- 1 | # teos-cli 2 | 3 | `teos-cli` is a command line interface to interact with the Eye of Satoshi watchtower server, written in Python3. 4 | 5 | ## Usage 6 | 7 | teos-cli [global options] command [command options] [arguments] 8 | 9 | #### Global options 10 | 11 | - `--rpcbind`: API server where to send the requests. Defaults to 'localhost' (modifiable in conf file). 12 | - `--rpcport`: API port where to send the requests. Defaults to '9814' (modifiable in conf file). 13 | - `-h --help`: shows a list of commands or help for a specific command. 14 | 15 | #### Commands 16 | 17 | The command line interface has, currently, the following commands: 18 | 19 | - `get_all_appointments`: returns a list of all the appointments currently in the watchtower. 20 | - `get_tower_info`: gets generic information about the tower. 21 | - `get_users`: gets the list of registered user ids. 22 | - `get_user`: gets information about a specific user. 23 | - `help`: shows a list of commands or help for a specific command. 24 | 25 | Run `teos-cli help ` for detailed information about each command and its arguments. -------------------------------------------------------------------------------- /teos/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/talaia-labs/python-teos/10d82c8588e4461a6db19bd27920914fc144fdd7/teos/cli/__init__.py -------------------------------------------------------------------------------- /teos/cli/rpc_client.py: -------------------------------------------------------------------------------- 1 | import json 2 | import functools 3 | import grpc 4 | 5 | from google.protobuf import json_format 6 | from google.protobuf.empty_pb2 import Empty 7 | 8 | from common.tools import is_compressed_pk, intify 9 | from common.exceptions import InvalidParameter 10 | 11 | from teos.protobuf.tower_services_pb2_grpc import TowerServicesStub 12 | from teos.protobuf.user_pb2 import GetUserRequest 13 | 14 | 15 | def to_json(obj): 16 | """ 17 | All conversions to json in this module should be consistent, therefore we restrict the options using 18 | this function. 19 | """ 20 | return json.dumps(obj, indent=4) 21 | 22 | 23 | def formatted(func): 24 | """ 25 | Transforms the given function by wrapping the return value with ``json_format.MessageToDict`` followed by 26 | json.dumps, in order to print the result in a prettyfied json format. 27 | """ 28 | 29 | @functools.wraps(func) 30 | def wrapper(*args, **kwargs): 31 | result = func(*args, **kwargs) 32 | result_dict = json_format.MessageToDict( 33 | result, including_default_value_fields=True, preserving_proto_field_name=True 34 | ) 35 | return to_json(intify(result_dict)) 36 | 37 | return wrapper 38 | 39 | 40 | class RPCClient: 41 | """ 42 | Creates and keeps a connection to the an RPC serving TowerServices. It has methods to call each of the 43 | available grpc services, and it returns a pretty-printed json response. 44 | Errors from the grpc calls are not handled. 45 | 46 | Args: 47 | rpc_host (:obj:`str`): the IP or host where the RPC server is hosted. 48 | rpc_port (:obj:`int`): the port where the RPC server is hosted. 49 | 50 | Attributes: 51 | stub: The rpc client stub. 52 | """ 53 | 54 | def __init__(self, rpc_host, rpc_port): 55 | self.rpc_host = rpc_host 56 | self.rpc_port = rpc_port 57 | channel = grpc.insecure_channel(f"{rpc_host}:{rpc_port}") 58 | self.stub = TowerServicesStub(channel) 59 | 60 | @formatted 61 | def get_all_appointments(self): 62 | """Gets a list of all the appointments in the watcher, and trackers in the responder.""" 63 | result = self.stub.get_all_appointments(Empty()) 64 | return result.appointments 65 | 66 | @formatted 67 | def get_tower_info(self): 68 | """Gets generic information about the tower.""" 69 | return self.stub.get_tower_info(Empty()) 70 | 71 | def get_users(self): 72 | """Gets the list of registered user ids.""" 73 | result = self.stub.get_users(Empty()) 74 | return to_json(list(result.user_ids)) 75 | 76 | @formatted 77 | def get_user(self, user_id): 78 | """ 79 | Gets information about a specific user. 80 | 81 | Args: 82 | user_id (:obj:`str`): the id of the requested user. 83 | 84 | Raises: 85 | :obj:`InvalidParameter`: if `user_id` is not in the valid format. 86 | """ 87 | 88 | if not is_compressed_pk(user_id): 89 | raise InvalidParameter("Invalid user id") 90 | 91 | result = self.stub.get_user(GetUserRequest(user_id=user_id)) 92 | return result.user 93 | 94 | def stop(self): 95 | """Stops TEOS gracefully.""" 96 | self.stub.stop(Empty()) 97 | -------------------------------------------------------------------------------- /teos/constants.py: -------------------------------------------------------------------------------- 1 | import logging.handlers 2 | 3 | SHUTDOWN_GRACE_TIME = 10 # Grace time in seconds to complete any pending call when stopping one of the services of TEOS 4 | OUTDATED_USERS_CACHE_SIZE_BLOCKS = 10 # Size of the users cache, in blocks 5 | -------------------------------------------------------------------------------- /teos/extended_appointment.py: -------------------------------------------------------------------------------- 1 | from common.appointment import Appointment 2 | 3 | 4 | class ExtendedAppointment(Appointment): 5 | """ 6 | The :class:`ExtendedAppointment` contains extended information about an appointment between a user and the tower. 7 | 8 | It extends the :class:`Appointment ` with information relevant to the tower, such 9 | as the ``user_id``, ``user_signature`` and ``start_block``. 10 | 11 | **All appointments are instances of** :obj:`Appointment ` **on the user-side but** 12 | :obj:`ExtendedAppointment` **on the tower-side.** 13 | 14 | Args: 15 | locator (:obj:`str`): A 16-byte hex-encoded value used by the tower to detect channel breaches. It serves as a 16 | trigger for the tower to decrypt and broadcast the penalty transaction. 17 | encrypted_blob (:obj:`str`): An encrypted blob of data containing a penalty transaction. The tower will decrypt 18 | it and broadcast the penalty transaction upon seeing a breach on the blockchain. 19 | to_self_delay (:obj:`int`): The ``to_self_delay`` encoded in the ``csv`` of the ``to_remote`` output of the 20 | commitment transaction that this appointment is covering. 21 | user_id (:obj:`str`): the public key that identifies the user (33-bytes hex str). 22 | user_signature (:obj:`str`): the signature of the appointment by the user. 23 | start_block (:obj:`str`): the block height at where the towers started watching for this appointment. 24 | """ 25 | 26 | def __init__(self, locator, encrypted_blob, to_self_delay, user_id, user_signature, start_block): 27 | super().__init__(locator, encrypted_blob, to_self_delay) 28 | self.user_id = user_id 29 | self.user_signature = user_signature 30 | self.start_block = start_block 31 | 32 | def get_summary(self): 33 | """ 34 | Returns the summary of an appointment, consisting on the ``locator``, and the ``user_id``. 35 | 36 | Returns: 37 | :obj:`dict`: The appointment summary. 38 | """ 39 | return {"locator": self.locator, "user_id": self.user_id} 40 | 41 | @classmethod 42 | def from_dict(cls, appointment_data): 43 | """ 44 | Builds an appointment from a dictionary. 45 | 46 | This method is useful to load data from a database. 47 | 48 | Args: 49 | appointment_data (:obj:`dict`): a dictionary containing the following keys: 50 | ``{locator, to_self_delay, encrypted_blob, user_id}`` 51 | 52 | Returns: 53 | :obj:`ExtendedAppointment `: An appointment initialized 54 | using the provided data. 55 | 56 | Raises: 57 | ValueError: If one of the mandatory keys is missing in ``appointment_data``. 58 | """ 59 | 60 | appointment = Appointment.from_dict(appointment_data) 61 | user_id = appointment_data.get("user_id") 62 | user_signature = appointment_data.get("user_signature") 63 | start_block = appointment_data.get("start_block") 64 | 65 | if any(v is None for v in [user_id, user_signature, start_block]): 66 | raise ValueError("Wrong appointment data, some fields are missing") 67 | 68 | return cls( 69 | appointment.locator, 70 | appointment.encrypted_blob, 71 | appointment.to_self_delay, 72 | user_id, 73 | user_signature, 74 | start_block, 75 | ) 76 | -------------------------------------------------------------------------------- /teos/gunicorn_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import structlog 3 | from teos.logger import add_api_component, timestamper 4 | 5 | 6 | logging_port = os.environ.get("LOG_SERVER_PORT") 7 | 8 | 9 | # Config dict that will be used by gunicorn 10 | logconfig_dict = { 11 | "version": 1, 12 | "disable_existing_loggers": False, 13 | "loggers": { 14 | "gunicorn.error": { 15 | "level": "INFO", 16 | "handlers": ["error_console"], 17 | "propagate": False, 18 | "qualname": "gunicorn.error", 19 | }, 20 | "gunicorn.access": { 21 | "level": "INFO", 22 | "handlers": ["console"], 23 | "propagate": False, 24 | "qualname": "gunicorn.access", 25 | }, 26 | }, 27 | "formatters": { 28 | "json_formatter": { 29 | "()": structlog.stdlib.ProcessorFormatter, 30 | "processor": structlog.processors.JSONRenderer(), 31 | "foreign_pre_chain": [timestamper, add_api_component], 32 | } 33 | }, 34 | "handlers": { 35 | "error_console": { 36 | "level": "DEBUG", 37 | "class": "teos.logger.FormattedSocketHandler", 38 | "host": "localhost", 39 | "port": logging_port, 40 | "formatter": "json_formatter", 41 | }, 42 | "console": { 43 | "level": "DEBUG", 44 | "class": "teos.logger.FormattedSocketHandler", 45 | "host": "localhost", 46 | "port": logging_port, 47 | "formatter": "json_formatter", 48 | }, 49 | }, 50 | } 51 | -------------------------------------------------------------------------------- /teos/help.py: -------------------------------------------------------------------------------- 1 | def show_usage(): 2 | return ( 3 | "USAGE: " 4 | "\n\tteosd [global options]" 5 | "\n\nGLOBAL OPTIONS (all modifiable in conf file):" 6 | "\n\t--apibind \t\tAddress that teos API will bind to. Defaults to 'localhost'." 7 | "\n\t--apiport \t\tPort that teos API will bind to. Defaults to '9814'." 8 | "\n\t--rpcbind \t\tAddress that teos RPC server will bind to. Defaults to 'localhost'." 9 | "\n\t--rpcport \t\tPort that teos RPC server will bind to. Defaults to '8814'." 10 | "\n\t--btcnetwork \t\tNetwork bitcoind is connected to. Either mainnet, testnet or regtest. Defaults to " 11 | "'mainnet'." 12 | "\n\t--btcrpcuser \t\tbitcoind rpcuser. Defaults to 'user'." 13 | "\n\t--btcrpcpassword \tbitcoind rpcpassword. Defaults to 'passwd'." 14 | "\n\t--btcrpcconnect \tbitcoind rpcconnect. Defaults to 'localhost'." 15 | "\n\t--btcrpcport \t\tbitcoind rpcport. Defaults to '8332'." 16 | "\n\t--btcfeedconnect \tbitcoind zmq hostname (for blocks). Defaults to 'localhost'." 17 | "\n\t--btcfeedport \t\tbitcoind zmq port (for blocks). Defaults to '28332'." 18 | "\n\t--datadir \t\tSpecify data directory. Defaults to '~\\.teos'." 19 | "\n\t--wsgi \t\t\tThe WSGI server used to run the API. Either 'gunicorn' or 'waitress'. Defaults to 'gunicorn'." 20 | "\n\t \t\t\tNotice 'gunicorn' does not work on Windows, so Windows users must use 'waitress'." 21 | "\n\t-d, --daemon \t\tRun in background as a daemon." 22 | "\n\t--overwritekey \t\tOverwrites the tower secret key. THIS IS IRREVERSIBLE AND WILL CHANGE YOUR TOWER ID." 23 | "\n\t-h, --help \t\tShows this message." 24 | ) 25 | -------------------------------------------------------------------------------- /teos/inspector.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from common.tools import is_locator 4 | from common.appointment import Appointment 5 | from common.constants import LOCATOR_LEN_HEX 6 | import common.errors as errors 7 | 8 | 9 | # FIXME: The inspector logs the wrong messages sent form the users. A possible attack surface would be to send a really 10 | # long field that, even if not accepted by TEOS, would be stored in the logs. This is a possible DoS surface 11 | # since teos would store any kind of message (no matter the length). Solution: truncate the length of the fields 12 | # stored + blacklist if multiple wrong requests are received. 13 | 14 | 15 | BLOCKS_IN_A_MONTH = 4320 # 4320 = roughly a month in blocks 16 | 17 | 18 | class InspectionFailed(Exception): 19 | """Raise this the inspector finds a problem with any of the appointment fields.""" 20 | 21 | def __init__(self, errno, reason): 22 | self.errno = errno 23 | self.reason = reason 24 | 25 | 26 | class Inspector: 27 | """ 28 | The :class:`Inspector` class is in charge of verifying that the appointment data provided by the user is correct. 29 | 30 | Args: 31 | min_to_self_delay (:obj:`int`): the minimum to_self_delay accepted in appointments. 32 | 33 | """ 34 | 35 | def __init__(self, min_to_self_delay): 36 | self.min_to_self_delay = min_to_self_delay 37 | 38 | def inspect(self, appointment_data): 39 | """ 40 | Inspects whether the data provided by the user is correct. 41 | 42 | Args: 43 | appointment_data (:obj:`dict`): a dictionary containing the appointment data. 44 | 45 | Returns: 46 | :obj:`Appointment `: An appointment initialized with the provided data. 47 | 48 | Raises: 49 | :obj:`InspectionFailed`: if any of the fields is wrong. 50 | """ 51 | 52 | if appointment_data is None: 53 | raise InspectionFailed(errors.APPOINTMENT_EMPTY_FIELD, "empty appointment received") 54 | elif not isinstance(appointment_data, dict): 55 | raise InspectionFailed(errors.APPOINTMENT_WRONG_FIELD, "wrong appointment format") 56 | 57 | self.check_locator(appointment_data.get("locator")) 58 | self.check_to_self_delay(appointment_data.get("to_self_delay")) 59 | self.check_blob(appointment_data.get("encrypted_blob")) 60 | 61 | return Appointment( 62 | appointment_data.get("locator"), 63 | appointment_data.get("encrypted_blob"), 64 | appointment_data.get("to_self_delay"), 65 | ) 66 | 67 | @staticmethod 68 | def check_locator(locator): 69 | """ 70 | Checks if the provided ``locator`` is correct. 71 | 72 | Locators must be 16-byte hex-encoded strings. 73 | 74 | Args: 75 | locator (:obj:`str`): the locator to be checked. 76 | 77 | Raises: 78 | :obj:`InspectionFailed`: if any of the fields is wrong. 79 | """ 80 | 81 | if locator is None: 82 | raise InspectionFailed(errors.APPOINTMENT_EMPTY_FIELD, "empty locator received") 83 | 84 | elif type(locator) != str: 85 | raise InspectionFailed( 86 | errors.APPOINTMENT_WRONG_FIELD_TYPE, "wrong locator data type ({})".format(type(locator)) 87 | ) 88 | 89 | elif len(locator) != LOCATOR_LEN_HEX: 90 | raise InspectionFailed(errors.APPOINTMENT_WRONG_FIELD_SIZE, "wrong locator size ({})".format(len(locator))) 91 | 92 | elif not is_locator(locator): 93 | raise InspectionFailed(errors.APPOINTMENT_WRONG_FIELD_FORMAT, "wrong locator format ({})".format(locator)) 94 | 95 | def check_to_self_delay(self, to_self_delay): 96 | """ 97 | Checks if the provided ``to_self_delay`` is correct. 98 | 99 | To self delays must be greater or equal to ``MIN_TO_SELF_DELAY``. 100 | 101 | Args: 102 | to_self_delay (:obj:`int`): The ``to_self_delay`` encoded in the ``csv`` of ``to_remote`` output of the 103 | commitment transaction this appointment is covering. 104 | 105 | Raises: 106 | :obj:`InspectionFailed`: if any of the fields is wrong. 107 | """ 108 | 109 | if to_self_delay is None: 110 | raise InspectionFailed(errors.APPOINTMENT_EMPTY_FIELD, "empty to_self_delay received") 111 | 112 | elif type(to_self_delay) != int: 113 | raise InspectionFailed( 114 | errors.APPOINTMENT_WRONG_FIELD_TYPE, "wrong to_self_delay data type ({})".format(type(to_self_delay)) 115 | ) 116 | 117 | elif to_self_delay > pow(2, 32): 118 | raise InspectionFailed( 119 | errors.APPOINTMENT_FIELD_TOO_BIG, 120 | "to_self_delay must fit the transaction nLockTime field ({} > {})".format(to_self_delay, pow(2, 32)), 121 | ) 122 | 123 | elif to_self_delay < self.min_to_self_delay: 124 | raise InspectionFailed( 125 | errors.APPOINTMENT_FIELD_TOO_SMALL, 126 | "to_self_delay too small. The to_self_delay should be at least {} (current: {})".format( 127 | self.min_to_self_delay, to_self_delay 128 | ), 129 | ) 130 | 131 | @staticmethod 132 | def check_blob(encrypted_blob): 133 | """ 134 | Checks if the provided ``encrypted_blob`` may be correct. 135 | 136 | Args: 137 | encrypted_blob (:obj:`str`): the encrypted blob to be checked (hex-encoded). 138 | 139 | Raises: 140 | :obj:`InspectionFailed`: if any of the fields is wrong. 141 | """ 142 | 143 | if encrypted_blob is None: 144 | raise InspectionFailed(errors.APPOINTMENT_EMPTY_FIELD, "empty encrypted_blob received") 145 | 146 | elif type(encrypted_blob) != str: 147 | raise InspectionFailed( 148 | errors.APPOINTMENT_WRONG_FIELD_TYPE, "wrong encrypted_blob data type ({})".format(type(encrypted_blob)) 149 | ) 150 | 151 | elif re.search(r"^[0-9A-Fa-f]+$", encrypted_blob) is None: 152 | raise InspectionFailed( 153 | errors.APPOINTMENT_WRONG_FIELD_FORMAT, "wrong encrypted_blob format ({})".format(encrypted_blob) 154 | ) 155 | -------------------------------------------------------------------------------- /teos/protobuf/README.md: -------------------------------------------------------------------------------- 1 | Protos for `teos` to manage the communication between the `HTTP_API` and the `RPC_API` with the `INTERNAL_API`. 2 | 3 | ![gRPC teos](https://user-images.githubusercontent.com/6665628/89121491-ac226a00-d4bf-11ea-8db1-3092e3ffe5f4.png) 4 | 5 | 6 | ## Compile protos 7 | 8 | Protos can be compiled using: 9 | 10 | ``` 11 | python -m grpc_tools.protoc -I=teos/protobuf/protos --python_out=teos/protobuf --grpc_python_out=teos/protobuf teos/protobuf/protos/*.proto 12 | ``` 13 | 14 | ### Things to consider 15 | 16 | `user_pb2_grpc.py` and `appointment_pb2_grpc.py` need to be deleted given they are basically empty. 17 | 18 | Currently, `teos.protobuf` is not prepended to the imports that need it for `pb2` files, e.g. 19 | 20 | ``` 21 | import appointment_pb2 as appointment__pb2 22 | import user_pb2 as user__pb2 23 | ``` 24 | 25 | is generated instead of 26 | 27 | ``` 28 | import teos.protobuf.appointment_pb2 as appointment__pb2 29 | import teos.protobuf.user_pb2 as user__pb2 30 | ``` 31 | 32 | -------------------------------------------------------------------------------- /teos/protobuf/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/talaia-labs/python-teos/10d82c8588e4461a6db19bd27920914fc144fdd7/teos/protobuf/__init__.py -------------------------------------------------------------------------------- /teos/protobuf/protos/appointment.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package teos.protobuf.protos.v1; 4 | 5 | import "google/protobuf/struct.proto"; 6 | 7 | message Appointment { 8 | /* 9 | Contains the basic information about an appointment (Watcher) and it's used for messages like 10 | AddAppointmentRequest or encapsulated inside AppointmentData for GetAppointmentResponse 11 | */ 12 | 13 | string locator = 1; 14 | string encrypted_blob = 2; 15 | uint32 to_self_delay = 3; 16 | 17 | } 18 | 19 | message Tracker { 20 | // It's the equivalent of an appointment message from data held by the Responder. 21 | 22 | string locator = 1; 23 | string dispute_txid = 2; 24 | string penalty_txid = 3; 25 | string penalty_rawtx = 4; 26 | } 27 | 28 | message AppointmentData { 29 | /* 30 | Encapsulates the data for a GetAppointmentResponse, given it can be an appointment (data is on the Watcher) or a 31 | tracker (data is on the Responder). 32 | */ 33 | 34 | oneof appointment_data { 35 | Appointment appointment = 1; 36 | Tracker tracker = 2; 37 | } 38 | } 39 | 40 | message AddAppointmentRequest { 41 | // Request to add an appointment to the backend, contains the appointment data and the user signature 42 | 43 | Appointment appointment = 1; 44 | string signature = 2; 45 | } 46 | 47 | message AddAppointmentResponse { 48 | /* 49 | Response to an AddAppointmentRequest, contains the locator to identify the added appointment, the tower signature, 50 | the block at which the tower has started (or will start) watching for the appointment, and the updated subscription 51 | information 52 | */ 53 | 54 | string locator = 1; 55 | uint32 start_block = 2; 56 | string signature = 3; 57 | uint32 available_slots = 4; 58 | uint32 subscription_expiry = 5; 59 | } 60 | 61 | message GetAppointmentRequest { 62 | // Request to get information about an appointment. Contains the appointment locator and a signature by the user 63 | 64 | string locator = 1; 65 | string signature = 2; 66 | } 67 | 68 | message GetAppointmentResponse { 69 | // Response to a GetAppointmentRequest. Contains the appointment data encapsulated in an AppointmentData message. 70 | 71 | AppointmentData appointment_data = 1; 72 | string status = 2; 73 | } 74 | 75 | message GetAllAppointmentsResponse { 76 | /* Response with data about all the appointments in the tower. Return is a Struct build from a Python dictionary, 77 | containing the watcher appointments and the responder trackers. 78 | */ 79 | 80 | google.protobuf.Struct appointments = 1; 81 | } 82 | -------------------------------------------------------------------------------- /teos/protobuf/protos/tower_services.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package teos.protobuf.protos.v1; 4 | 5 | import "appointment.proto"; 6 | import "user.proto"; 7 | import "google/protobuf/empty.proto"; 8 | 9 | message GetTowerInfoResponse { 10 | // Response with information about the tower. 11 | 12 | uint32 n_watcher_appointments = 1; 13 | uint32 n_responder_trackers = 2; 14 | uint32 n_registered_users = 3; 15 | string tower_id = 4; 16 | } 17 | 18 | service TowerServices { 19 | rpc register(RegisterRequest) returns (RegisterResponse) {} 20 | rpc add_appointment(AddAppointmentRequest) returns (AddAppointmentResponse) {} 21 | rpc get_appointment(GetAppointmentRequest) returns (GetAppointmentResponse) {} 22 | rpc get_all_appointments(google.protobuf.Empty) returns (GetAllAppointmentsResponse) {} 23 | rpc get_tower_info(google.protobuf.Empty) returns (GetTowerInfoResponse) {} 24 | rpc get_users(google.protobuf.Empty) returns (GetUsersResponse) {} 25 | rpc get_user(GetUserRequest) returns (GetUserResponse) {} 26 | rpc get_subscription_info(GetSubscriptionInfoRequest) returns (GetUserResponse) {} 27 | rpc stop(google.protobuf.Empty) returns (google.protobuf.Empty) {} 28 | } 29 | -------------------------------------------------------------------------------- /teos/protobuf/protos/user.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package teos.protobuf.protos.v1; 4 | 5 | import "google/protobuf/struct.proto"; 6 | 7 | message RegisterRequest { 8 | // Requests a user registration with the tower. Contains the user_id in the form of a compressed ecdsa pk 9 | string user_id = 1; 10 | } 11 | 12 | message RegisterResponse { 13 | // Response to a RegisterRequest, contains the registration information alongside the tower signature of the agreement 14 | 15 | string user_id = 1; 16 | uint32 available_slots = 2; 17 | uint32 subscription_expiry = 3; 18 | string subscription_signature = 4; 19 | } 20 | 21 | message GetUserRequest { 22 | // Request to get information about a specific user. Contains the user id. 23 | 24 | string user_id = 1; 25 | } 26 | 27 | message GetUserResponse { 28 | /* Response with the information the tower has about a specific user. Return is a Struct build from a 29 | Python dictionary, containing the user's info. 30 | */ 31 | 32 | google.protobuf.Struct user = 1; 33 | } 34 | 35 | message GetUsersResponse { 36 | // Response with information about all the users registered with the tower. Contains a list of user ids. 37 | 38 | repeated string user_ids = 1; 39 | } 40 | 41 | message GetSubscriptionInfoRequest { 42 | // Request to get a specific user's subscription info. 43 | 44 | string signature = 1; 45 | } 46 | -------------------------------------------------------------------------------- /teos/rpc.py: -------------------------------------------------------------------------------- 1 | import grpc 2 | import functools 3 | from concurrent import futures 4 | from signal import signal, SIGINT 5 | 6 | from teos.tools import ignore_signal 7 | from teos.logger import setup_logging, get_logger 8 | from teos.constants import SHUTDOWN_GRACE_TIME 9 | from teos.protobuf.tower_services_pb2_grpc import ( 10 | TowerServicesStub, 11 | TowerServicesServicer, 12 | add_TowerServicesServicer_to_server, 13 | ) 14 | 15 | 16 | class RPC: 17 | """ 18 | The :obj:`RPC` is an external RPC server offered by tower to receive requests from the CLI. 19 | This acts as a proxy between the internal api and the CLI. 20 | 21 | Args: 22 | rpc_bind (:obj:`str`): the IP or host where the RPC server will be hosted. 23 | rpc_port (:obj:`int`): the port where the RPC server will be hosted. 24 | internal_api_endpoint (:obj:`str`): the endpoint where to reach the internal (gRPC) api. 25 | 26 | Attributes: 27 | logger (:obj:`Logger `): The logger for this component. 28 | endpoint (:obj:`str`): The endpoint where the RPC api will be served (external gRPC server). 29 | rpc_server (:obj:`grpc.Server `): The non-started gRPC server instance. 30 | """ 31 | 32 | def __init__(self, rpc_bind, rpc_port, internal_api_endpoint): 33 | self.logger = get_logger(component=RPC.__name__) 34 | self.endpoint = f"{rpc_bind}:{rpc_port}" 35 | self.rpc_server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) 36 | self.rpc_server.add_insecure_port(self.endpoint) 37 | add_TowerServicesServicer_to_server(_RPC(internal_api_endpoint, self.logger), self.rpc_server) 38 | 39 | def teardown(self): 40 | self.logger.info("Stopping") 41 | stopped_event = self.rpc_server.stop(SHUTDOWN_GRACE_TIME) 42 | stopped_event.wait() 43 | self.logger.info("Stopped") 44 | 45 | 46 | def forward_errors(func): 47 | """ 48 | Transforms ``func`` in order to forward any ``grpc.RPCError`` returned by the upstream grpc as the result of the 49 | current grpc call. 50 | """ 51 | 52 | @functools.wraps(func) 53 | def wrapper(self, request, context, *args, **kwargs): 54 | try: 55 | return func(self, request, context, *args, **kwargs) 56 | except grpc.RpcError as e: 57 | context.set_details(e.details()) 58 | context.set_code(e.code()) 59 | 60 | return wrapper 61 | 62 | 63 | class _RPC(TowerServicesServicer): 64 | """ 65 | This represents the RPC server provider and implements all the methods that can be accessed using the CLI. 66 | 67 | Args: 68 | internal_api_endpoint (:obj:`str`): the endpoint where to reach the internal (gRPC) api. 69 | logger (:obj:`Logger `): the logger for this component. 70 | 71 | Attributes: 72 | stub (:obj:`TowerServicesStub`): The rpc client stub. 73 | """ 74 | 75 | def __init__(self, internal_api_endpoint, logger): 76 | self.logger = logger 77 | self.internal_api_endpoint = internal_api_endpoint 78 | channel = grpc.insecure_channel(self.internal_api_endpoint) 79 | self.stub = TowerServicesStub(channel) 80 | 81 | @forward_errors 82 | def get_all_appointments(self, request, context): 83 | return self.stub.get_all_appointments(request) 84 | 85 | @forward_errors 86 | def get_tower_info(self, request, context): 87 | return self.stub.get_tower_info(request) 88 | 89 | @forward_errors 90 | def get_users(self, request, context): 91 | return self.stub.get_users(request) 92 | 93 | @forward_errors 94 | def get_user(self, request, context): 95 | return self.stub.get_user(request) 96 | 97 | @forward_errors 98 | def stop(self, request, context): 99 | return self.stub.stop(request) 100 | 101 | 102 | def serve(rpc_bind, rpc_port, internal_api_endpoint, logging_port, stop_event): 103 | """ 104 | Serves the external RPC API at the given endpoint and connects it to the internal api. 105 | 106 | This method will serve and hold until the main process is stop or a stop signal is received. 107 | 108 | Args: 109 | rpc_bind (:obj:`str`): the IP or host where the RPC server will be hosted. 110 | rpc_port (:obj:`int`): the port where the RPC server will be hosted. 111 | internal_api_endpoint (:obj:`str`): the endpoint where to reach the internal (gRPC) api. 112 | logging_port (:obj:`int`): the port where the logging server can be reached (localhost:logging_port) 113 | stop_event (:obj:`multiprocessing.Event`) the Event that this service will monitor. The rpc server will 114 | initiate a graceful shutdown once this event is set. 115 | """ 116 | 117 | setup_logging(logging_port) 118 | rpc = RPC(rpc_bind, rpc_port, internal_api_endpoint) 119 | # Ignores SIGINT so the main process can handle the teardown 120 | signal(SIGINT, ignore_signal) 121 | rpc.rpc_server.start() 122 | 123 | rpc.logger.info(f"Initialized. Serving at {rpc.endpoint}") 124 | 125 | stop_event.wait() 126 | 127 | rpc.logger.info("Stopping") 128 | stopped_event = rpc.rpc_server.stop(SHUTDOWN_GRACE_TIME) 129 | stopped_event.wait() 130 | rpc.logger.info("Stopped") 131 | -------------------------------------------------------------------------------- /teos/template.conf: -------------------------------------------------------------------------------- 1 | [bitcoind] 2 | btc_rpc_user = user 3 | btc_rpc_password = passwd 4 | btc_rpc_connect = localhost 5 | btc_network = mainnet 6 | 7 | # [zmq] 8 | btc_feed_protocol = tcp 9 | btc_feed_connect = localhost 10 | btc_feed_port = 28332 11 | 12 | [teos] 13 | api_bind = localhost 14 | api_port = 9814 15 | rpc_bind = localhost 16 | rpc_port = 8814 17 | subscription_slots = 100 18 | max_appointments = 1000000 19 | expiry_delta = 6 20 | min_to_self_delay = 20 21 | -------------------------------------------------------------------------------- /teos/tools.py: -------------------------------------------------------------------------------- 1 | from socket import timeout 2 | from http.client import HTTPException 3 | 4 | from teos.utils.auth_proxy import AuthServiceProxy, JSONRPCException 5 | 6 | from common.constants import MAINNET_RPC_PORT, TESTNET_RPC_PORT, REGTEST_RPC_PORT 7 | 8 | """ 9 | Tools is a module with general methods that can used by different entities in the codebase. 10 | """ 11 | 12 | 13 | # NOTCOVERED 14 | def bitcoin_cli(btc_connect_params): 15 | """ 16 | An ``http`` connection with ``bitcoind`` using the ``json-rpc`` interface. 17 | 18 | Args: 19 | btc_connect_params (:obj:`dict`): a dictionary with the parameters to connect to bitcoind 20 | (``rpc user, rpc password, host and port``) 21 | 22 | Returns: 23 | :obj:`AuthServiceProxy `: An authenticated service proxy to ``bitcoind`` 24 | that can be used to send ``json-rpc`` commands. 25 | """ 26 | 27 | return AuthServiceProxy( 28 | "http://%s:%s@%s:%d" 29 | % ( 30 | btc_connect_params.get("BTC_RPC_USER"), 31 | btc_connect_params.get("BTC_RPC_PASSWORD"), 32 | btc_connect_params.get("BTC_RPC_CONNECT"), 33 | btc_connect_params.get("BTC_RPC_PORT"), 34 | ) 35 | ) 36 | 37 | 38 | # NOTCOVERED 39 | def can_connect_to_bitcoind(btc_connect_params): 40 | """ 41 | Checks if the tower has connection to ``bitcoind``. 42 | 43 | Args: 44 | btc_connect_params (:obj:`dict`): a dictionary with the parameters to connect to bitcoind 45 | (``rpc user, rpc password, host and port``). 46 | Returns: 47 | :obj:`bool`: True if the connection can be established. False otherwise. 48 | """ 49 | 50 | can_connect = True 51 | 52 | try: 53 | bitcoin_cli(btc_connect_params).help() 54 | except (timeout, ConnectionRefusedError, JSONRPCException, HTTPException, OSError): 55 | can_connect = False 56 | 57 | return can_connect 58 | 59 | 60 | def in_correct_network(btc_connect_params, network): 61 | """ 62 | Checks if ``bitcoind`` and the tower are configured to run in the same network (``mainnet``, ``testnet`` or 63 | ``regtest``) 64 | 65 | Args: 66 | btc_connect_params (:obj:`dict`): a dictionary with the parameters to connect to bitcoind 67 | (rpc user, rpc password, host and port) 68 | network (:obj:`str`): the network the tower is connected to. 69 | 70 | Returns: 71 | :obj:`bool`: True if the network configuration matches. False otherwise. 72 | """ 73 | 74 | mainnet_genesis_block_hash = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f" 75 | testnet3_genesis_block_hash = "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943" 76 | correct_network = False 77 | 78 | genesis_block_hash = bitcoin_cli(btc_connect_params).getblockhash(0) 79 | 80 | if network == "mainnet" and genesis_block_hash == mainnet_genesis_block_hash: 81 | correct_network = True 82 | elif network == "testnet" and genesis_block_hash == testnet3_genesis_block_hash: 83 | correct_network = True 84 | elif network == "regtest" and genesis_block_hash not in [mainnet_genesis_block_hash, testnet3_genesis_block_hash]: 85 | correct_network = True 86 | 87 | return correct_network 88 | 89 | 90 | def get_default_rpc_port(network): 91 | """ 92 | Returns the default RPC port given a network name. 93 | 94 | Args: 95 | network (:obj:`str`): the network name. Either ``mainnet``, ``testnet`` or ``regtest``. 96 | 97 | Returns: 98 | :obj:`int`: The default RPC port depending on the given network name. 99 | 100 | Raises: 101 | :obj:`ValueError`: If the network is not mainnet, testnet or regtest. 102 | """ 103 | 104 | if network == "mainnet": 105 | return MAINNET_RPC_PORT 106 | elif network == "testnet": 107 | return TESTNET_RPC_PORT 108 | elif network == "regtest": 109 | return REGTEST_RPC_PORT 110 | else: 111 | raise ValueError("Wrong Bitcoin network. Expected: mainnet, testnet or regtest. Received: {}".format(network)) 112 | 113 | 114 | # Convenience method to ignore a signal 115 | def ignore_signal(_, __): 116 | """Placeholder function to ignore signals sent to child processes so the main process can manage the teardown.""" 117 | pass 118 | -------------------------------------------------------------------------------- /teos/users_dbm.py: -------------------------------------------------------------------------------- 1 | import json 2 | import plyvel 3 | 4 | from teos.logger import get_logger 5 | from common.db_manager import DBManager 6 | from common.tools import is_compressed_pk 7 | 8 | 9 | class UsersDBM(DBManager): 10 | """ 11 | The :class:`UsersDBM` is in charge of interacting with the users database (``LevelDB``). 12 | Keys and values are stored as bytes in the database but processed as strings by the manager. 13 | 14 | Args: 15 | db_path (:obj:`str`): the path (relative or absolute) to the system folder containing the database. A fresh 16 | database will be created if the specified path does not contain one. 17 | 18 | Raises: 19 | :obj:`ValueError`: If the provided ``db_path`` is not a string. 20 | :obj:`plyvel.Error`: If the db is currently unavailable (being used by another process). 21 | 22 | Attributes: 23 | logger (:obj:`Logger `): The logger for this component. 24 | """ 25 | 26 | def __init__(self, db_path): 27 | self.logger = get_logger(component=UsersDBM.__name__) 28 | 29 | if not isinstance(db_path, str): 30 | raise ValueError("db_path must be a valid path/name") 31 | 32 | try: 33 | super().__init__(db_path) 34 | 35 | except plyvel.Error as e: 36 | if "LOCK: Resource temporarily unavailable" in str(e): 37 | self.logger.info("The db is already being used by another process (LOCK)") 38 | 39 | raise e 40 | 41 | def store_user(self, user_id, user_data): 42 | """ 43 | Stores a user record to the database. ``user_pk`` is used as identifier. 44 | 45 | Args: 46 | user_id (:obj:`str`): a 33-byte hex-encoded string identifying the user. 47 | user_data (:obj:`dict`): the user associated data, as a dictionary. 48 | 49 | Returns: 50 | :obj:`bool`: True if the user was stored in the database, False otherwise. 51 | """ 52 | 53 | if is_compressed_pk(user_id): 54 | try: 55 | self.logger.info("Adding user to Gatekeeper's db", user_id=user_id) 56 | self.create_entry(user_id, json.dumps(user_data)) 57 | return True 58 | 59 | except json.JSONDecodeError: 60 | self.logger.info( 61 | "Couldn't add user to db. Wrong user data format", user_id=user_id, user_data=user_data 62 | ) 63 | return False 64 | 65 | except TypeError: 66 | self.logger.info("Couldn't add user to db", user_id=user_id, user_data=user_data) 67 | return False 68 | 69 | except RuntimeError as e: 70 | self.logger.error(str(e)) 71 | raise e 72 | else: 73 | self.logger.info("Couldn't add user to db. Wrong pk format", user_id=user_id, user_data=user_data) 74 | return False 75 | 76 | def load_user(self, user_id): 77 | """ 78 | Loads a user record from the database using the ``user_pk`` as identifier. 79 | 80 | Args: 81 | 82 | user_id (:obj:`str`): a 33-byte hex-encoded string identifying the user. 83 | 84 | Returns: 85 | :obj:`dict`: A dictionary containing the user data if the ``key`` is found. 86 | 87 | Returns :obj:`None` otherwise. 88 | """ 89 | 90 | try: 91 | data = self.load_entry(user_id) 92 | data = json.loads(data) 93 | except (TypeError, json.decoder.JSONDecodeError) as e: 94 | self.logger.error(str(e)) 95 | data = None 96 | 97 | except RuntimeError as e: 98 | self.logger.error(str(e)) 99 | raise e 100 | 101 | return data 102 | 103 | def delete_user(self, user_id): 104 | """ 105 | Deletes a user record from the database. 106 | 107 | Args: 108 | user_id (:obj:`str`): a 33-byte hex-encoded string identifying the user. 109 | 110 | Returns: 111 | :obj:`bool`: True if the user was deleted from the database or it was non-existent, False otherwise. 112 | """ 113 | 114 | try: 115 | self.logger.info("Deleting user from Gatekeeper's db", uuid=user_id) 116 | self.delete_entry(user_id) 117 | return True 118 | 119 | except TypeError: 120 | self.logger.info("Cannot delete user from db, user key has wrong type", uuid=user_id) 121 | return False 122 | 123 | except RuntimeError as e: 124 | self.logger.error(str(e)) 125 | raise e 126 | 127 | def load_all_users(self): 128 | """ 129 | Loads all user records from the database. 130 | 131 | Returns: 132 | :obj:`dict`: A dictionary containing all users indexed by ``user_pk``. 133 | 134 | Returns an empty dictionary if no data is found. 135 | """ 136 | 137 | data = {} 138 | 139 | try: 140 | for k, v in self.db.iterator(): 141 | # Get uuid and appointment_data from the db 142 | user_id = k.decode("utf-8") 143 | data[user_id] = json.loads(v) 144 | except RuntimeError as e: 145 | self.logger.error(str(e)) 146 | raise e 147 | 148 | return data 149 | -------------------------------------------------------------------------------- /teos/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/talaia-labs/python-teos/10d82c8588e4461a6db19bd27920914fc144fdd7/teos/utils/__init__.py -------------------------------------------------------------------------------- /teos/utils/rpc_errors.py: -------------------------------------------------------------------------------- 1 | # Ported from https://github.com/bitcoin/bitcoin/blob/0.18/src/rpc/protocol.h 2 | 3 | # General application defined errors 4 | RPC_MISC_ERROR = -1 # std::exception thrown in command handling 5 | RPC_TYPE_ERROR = -3 # Unexpected type was passed as parameter 6 | RPC_INVALID_ADDRESS_OR_KEY = -5 # Invalid address or key 7 | RPC_OUT_OF_MEMORY = -7 # Ran out of memory during operation 8 | RPC_INVALID_PARAMETER = -8 # Invalid missing or duplicate parameter 9 | RPC_DATABASE_ERROR = -20 # Database error 10 | RPC_DESERIALIZATION_ERROR = -22 # Error parsing or validating structure in raw format 11 | RPC_VERIFY_ERROR = -25 # General error during transaction or block submission 12 | RPC_VERIFY_REJECTED = -26 # Transaction or block was rejected by network rules 13 | RPC_VERIFY_ALREADY_IN_CHAIN = -27 # Transaction already in chain 14 | RPC_IN_WARMUP = -28 # Client still warming up 15 | RPC_METHOD_DEPRECATED = -32 # RPC method is deprecated 16 | 17 | # Aliases for backward compatibility 18 | RPC_TRANSACTION_ERROR = RPC_VERIFY_ERROR 19 | RPC_TRANSACTION_REJECTED = RPC_VERIFY_REJECTED 20 | RPC_TRANSACTION_ALREADY_IN_CHAIN = RPC_VERIFY_ALREADY_IN_CHAIN 21 | 22 | # P2P client errors 23 | RPC_CLIENT_NOT_CONNECTED = -9 # Bitcoin is not connected 24 | RPC_CLIENT_IN_INITIAL_DOWNLOAD = -10 # Still downloading initial blocks 25 | RPC_CLIENT_NODE_ALREADY_ADDED = -23 # Node is already added 26 | RPC_CLIENT_NODE_NOT_ADDED = -24 # Node has not been added before 27 | RPC_CLIENT_NODE_NOT_CONNECTED = -29 # Node to disconnect not found in connected nodes 28 | RPC_CLIENT_INVALID_IP_OR_SUBNET = -30 # Invalid IP/Subnet 29 | RPC_CLIENT_P2P_DISABLED = -31 # No valid connection manager instance found 30 | 31 | # Wallet errors 32 | RPC_WALLET_ERROR = -4 # Unspecified problem with wallet (key not found etc.) 33 | RPC_WALLET_INSUFFICIENT_FUNDS = -6 # Not enough funds in wallet or account 34 | RPC_WALLET_INVALID_LABEL_NAME = -11 # Invalid label name 35 | RPC_WALLET_KEYPOOL_RAN_OUT = -12 # Keypool ran out call keypoolrefill first 36 | RPC_WALLET_UNLOCK_NEEDED = -13 # Enter the wallet passphrase with walletpassphrase first 37 | RPC_WALLET_PASSPHRASE_INCORRECT = -14 # The wallet passphrase entered was incorrect 38 | RPC_WALLET_WRONG_ENC_STATE = -15 # Command given in wrong wallet encryption state (encrypting an encrypted wallet etc.) 39 | RPC_WALLET_ENCRYPTION_FAILED = -16 # Failed to encrypt the wallet 40 | RPC_WALLET_ALREADY_UNLOCKED = -17 # Wallet is already unlocked 41 | RPC_WALLET_NOT_FOUND = -18 # Invalid wallet specified 42 | RPC_WALLET_NOT_SPECIFIED = -19 # No wallet specified (error when there are multiple wallets loaded) 43 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/talaia-labs/python-teos/10d82c8588e4461a6db19bd27920914fc144fdd7/test/__init__.py -------------------------------------------------------------------------------- /test/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/talaia-labs/python-teos/10d82c8588e4461a6db19bd27920914fc144fdd7/test/common/__init__.py -------------------------------------------------------------------------------- /test/common/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/talaia-labs/python-teos/10d82c8588e4461a6db19bd27920914fc144fdd7/test/common/unit/__init__.py -------------------------------------------------------------------------------- /test/common/unit/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import random 3 | from shutil import rmtree 4 | 5 | from common.db_manager import DBManager 6 | from common.constants import LOCATOR_LEN_BYTES 7 | 8 | 9 | @pytest.fixture(scope="session", autouse=True) 10 | def prng_seed(): 11 | random.seed(0) 12 | 13 | 14 | @pytest.fixture(scope="module") 15 | def db_manager(): 16 | manager = DBManager("test_db") 17 | # Add last know block for the Responder in the db 18 | 19 | yield manager 20 | 21 | manager.db.close() 22 | rmtree("test_db") 23 | 24 | 25 | @pytest.fixture 26 | def appointment_data(): 27 | locator = get_random_value_hex(LOCATOR_LEN_BYTES) 28 | start_time = 100 29 | end_time = 120 30 | to_self_delay = 20 31 | encrypted_blob_data = get_random_value_hex(100) 32 | 33 | return { 34 | "locator": locator, 35 | "start_time": start_time, 36 | "end_time": end_time, 37 | "to_self_delay": to_self_delay, 38 | "encrypted_blob": encrypted_blob_data, 39 | } 40 | 41 | 42 | def get_random_value_hex(nbytes): 43 | pseudo_random_value = random.getrandbits(8 * nbytes) 44 | prv_hex = "{:x}".format(pseudo_random_value) 45 | return prv_hex.zfill(2 * nbytes) 46 | -------------------------------------------------------------------------------- /test/common/unit/test_appointment.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from common.appointment import Appointment 4 | 5 | 6 | def test_init_appointment(appointment_data): 7 | # The appointment has no checks whatsoever, since the inspector is the one taking care or that, and the only one 8 | # creating appointments. 9 | appointment = Appointment( 10 | appointment_data["locator"], appointment_data["encrypted_blob"], appointment_data["to_self_delay"] 11 | ) 12 | 13 | assert ( 14 | appointment_data["locator"] == appointment.locator 15 | and appointment_data["to_self_delay"] == appointment.to_self_delay 16 | and appointment_data["encrypted_blob"] == appointment.encrypted_blob 17 | ) 18 | 19 | 20 | def test_to_dict(appointment_data): 21 | appointment = Appointment( 22 | appointment_data["locator"], appointment_data["encrypted_blob"], appointment_data["to_self_delay"] 23 | ) 24 | 25 | dict_appointment = appointment.to_dict() 26 | 27 | assert ( 28 | appointment_data["locator"] == dict_appointment["locator"] 29 | and appointment_data["to_self_delay"] == dict_appointment["to_self_delay"] 30 | and appointment_data["encrypted_blob"] == dict_appointment["encrypted_blob"] 31 | ) 32 | 33 | 34 | def test_from_dict(appointment_data): 35 | # The appointment should be build if we don't miss any field 36 | appointment = Appointment.from_dict(appointment_data) 37 | assert isinstance(appointment, Appointment) 38 | 39 | # Otherwise it should fail 40 | for key in appointment_data.keys(): 41 | prev_val = appointment_data[key] 42 | appointment_data[key] = None 43 | 44 | with pytest.raises(ValueError, match="Wrong appointment data"): 45 | Appointment.from_dict(appointment_data) 46 | appointment_data[key] = prev_val 47 | 48 | 49 | def test_serialize(appointment_data): 50 | # From the tower end, appointments are only created if they pass the inspector tests, so not covering weird formats. 51 | # Serialize may fail if, from the user end, the user tries to do it with an weird appointment. Not critical. 52 | 53 | appointment = Appointment.from_dict(appointment_data) 54 | serialized_appointment = appointment.serialize() 55 | 56 | # Size must be 16 + len(encrypted_blob) + 4 57 | assert len(serialized_appointment) >= 20 58 | assert isinstance(serialized_appointment, bytes) 59 | 60 | locator = serialized_appointment[:16] 61 | encrypted_blob = serialized_appointment[16:-4] 62 | to_self_delay = serialized_appointment[-4:] 63 | 64 | assert locator.hex() == appointment.locator 65 | assert encrypted_blob.hex() == appointment.encrypted_blob 66 | assert int.from_bytes(to_self_delay, "big") == appointment.to_self_delay 67 | -------------------------------------------------------------------------------- /test/common/unit/test_db_manager.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import pytest 4 | 5 | from common.db_manager import DBManager 6 | from test.common.unit.conftest import get_random_value_hex 7 | 8 | 9 | def open_create_db(db_path): 10 | 11 | try: 12 | db_manager = DBManager(db_path) 13 | 14 | return db_manager 15 | 16 | except ValueError: 17 | return False 18 | 19 | 20 | def test_init(): 21 | db_path = "init_test_db" 22 | 23 | # First we check if the db exists, and if so we delete it 24 | if os.path.isdir(db_path): 25 | shutil.rmtree(db_path) 26 | 27 | # Check that the db can be created if it does not exist 28 | db_manager = open_create_db(db_path) 29 | assert isinstance(db_manager, DBManager) 30 | db_manager.db.close() 31 | 32 | # Check that we can open an already create db 33 | db_manager = open_create_db(db_path) 34 | assert isinstance(db_manager, DBManager) 35 | db_manager.db.close() 36 | 37 | # Check we cannot create/open a db with an invalid parameter 38 | assert open_create_db(0) is False 39 | 40 | # Removing test db 41 | shutil.rmtree(db_path) 42 | 43 | 44 | def test_create_entry(db_manager): 45 | key = get_random_value_hex(16) 46 | value = get_random_value_hex(32) 47 | 48 | # Adding a value with no prefix should work 49 | db_manager.create_entry(key, value) 50 | assert db_manager.db.get(key.encode("utf-8")).decode("utf-8") == value 51 | 52 | # Prefixing the key would require the prefix to load 53 | key = get_random_value_hex(16) 54 | prefix = "w" 55 | db_manager.create_entry(key, value, prefix=prefix) 56 | 57 | assert db_manager.db.get((prefix + key).encode("utf-8")).decode("utf-8") == value 58 | assert db_manager.db.get(key.encode("utf-8")) is None 59 | 60 | # Keys, prefixes, and values of wrong format should fail 61 | with pytest.raises(TypeError): 62 | db_manager.create_entry(key=None) 63 | 64 | with pytest.raises(TypeError): 65 | db_manager.create_entry(key=key, value=None) 66 | 67 | with pytest.raises(TypeError): 68 | db_manager.create_entry(key=key, value=value, prefix=1) 69 | 70 | 71 | def test_load_entry(db_manager): 72 | key = get_random_value_hex(16) 73 | value = get_random_value_hex(32) 74 | 75 | # Loading an existing key should work 76 | db_manager.db.put(key.encode("utf-8"), value.encode("utf-8")) 77 | assert db_manager.load_entry(key) == value.encode("utf-8") 78 | 79 | # Adding an existing prefix should work 80 | assert db_manager.load_entry(key[2:], prefix=key[:2]) == value.encode("utf-8") 81 | 82 | # Adding a non-existing prefix should return None 83 | assert db_manager.load_entry(key, prefix=get_random_value_hex(2)) is None 84 | 85 | # Loading a non-existing entry should return None 86 | assert db_manager.load_entry(get_random_value_hex(16)) is None 87 | 88 | # Trying to load a non str key or prefix should fail 89 | with pytest.raises(TypeError): 90 | db_manager.load_entry(None) 91 | 92 | with pytest.raises(TypeError): 93 | db_manager.load_entry(get_random_value_hex(16), prefix=1) 94 | 95 | 96 | def test_delete_entry(db_manager): 97 | # Let's get the key all the things we've wrote so far in the db and empty the db. 98 | data = [k.decode("utf-8") for k, v in db_manager.db.iterator()] 99 | for key in data: 100 | db_manager.delete_entry(key) 101 | 102 | assert len([k for k, v in db_manager.db.iterator()]) == 0 103 | 104 | # The same works if a prefix is provided. 105 | prefix = "r" 106 | key = get_random_value_hex(16) 107 | value = get_random_value_hex(32) 108 | db_manager.create_entry(key, value, prefix) 109 | 110 | # Checks it's there 111 | assert db_manager.db.get((prefix + key).encode("utf-8")).decode("utf-8") == value 112 | 113 | # And now it's gone 114 | db_manager.delete_entry(key, prefix) 115 | assert db_manager.db.get((prefix + key).encode("utf-8")) is None 116 | 117 | # Deleting a non-existing key should be fine 118 | db_manager.delete_entry(key, prefix) 119 | 120 | # Trying to delete a non str key or prefix should fail 121 | with pytest.raises(TypeError): 122 | db_manager.delete_entry(None) 123 | 124 | with pytest.raises(TypeError): 125 | db_manager.delete_entry(get_random_value_hex(16), prefix=1) 126 | -------------------------------------------------------------------------------- /test/common/unit/test_receipts.py: -------------------------------------------------------------------------------- 1 | import struct 2 | import pytest 3 | import pyzbase32 4 | from coincurve import PrivateKey 5 | 6 | from common import receipts as receipts 7 | from common.cryptographer import Cryptographer 8 | from common.exceptions import InvalidParameter 9 | 10 | from test.common.unit.conftest import get_random_value_hex 11 | 12 | 13 | def test_create_registration_receipt(): 14 | # Not much to test here, basically making sure the fields are in the correct order 15 | # The receipt format is user_id | available_slots | subscription_expiry 16 | 17 | user_id = "02" + get_random_value_hex(32) 18 | available_slots = 100 19 | subscription_expiry = 4320 20 | 21 | registration_receipt = receipts.create_registration_receipt(user_id, available_slots, subscription_expiry) 22 | 23 | assert registration_receipt[:33].hex() == user_id 24 | assert int.from_bytes(registration_receipt[33:37], "big") == available_slots 25 | assert int.from_bytes(registration_receipt[37:], "big") == subscription_expiry 26 | 27 | 28 | def test_create_registration_receipt_wrong_inputs(): 29 | user_id = "02" + get_random_value_hex(32) 30 | available_slots = 100 31 | subscription_expiry = 4320 32 | 33 | wrong_user_ids = ["01" + get_random_value_hex(32), "04" + get_random_value_hex(31), "06" + get_random_value_hex(33)] 34 | no_int = [{}, object, "", [], 3.4, None] 35 | overflow_iu4nt = pow(2, 32) 36 | 37 | for wrong_param in wrong_user_ids + no_int: 38 | with pytest.raises(InvalidParameter, match="public key does not match expected format"): 39 | receipts.create_registration_receipt(wrong_param, available_slots, subscription_expiry) 40 | with pytest.raises(InvalidParameter, match="available_slots must be a 4-byte unsigned integer"): 41 | receipts.create_registration_receipt(user_id, wrong_param, subscription_expiry) 42 | with pytest.raises(InvalidParameter, match="subscription_expiry must be a 4-byte unsigned integer"): 43 | receipts.create_registration_receipt(user_id, available_slots, wrong_param) 44 | 45 | # Same for overflow u4int 46 | with pytest.raises(InvalidParameter, match="available_slots must be a 4-byte unsigned integer"): 47 | receipts.create_registration_receipt(user_id, overflow_iu4nt, subscription_expiry) 48 | with pytest.raises(InvalidParameter, match="subscription_expiry must be a 4-byte unsigned integer"): 49 | receipts.create_registration_receipt(user_id, available_slots, overflow_iu4nt) 50 | 51 | 52 | def test_create_appointment_receipt(appointment_data): 53 | # Not much to test here, basically making sure the fields are in the correct order 54 | # The receipt format is user_signature | start_block 55 | sk = PrivateKey.from_int(42) 56 | data = get_random_value_hex(120) 57 | signature = Cryptographer.sign(data.encode("utf-8"), sk) 58 | start_block = 200 59 | 60 | receipt = receipts.create_appointment_receipt(signature, start_block) 61 | 62 | assert pyzbase32.encode_bytes(receipt[:-4]).decode() == signature 63 | assert struct.unpack(">I", receipt[-4:])[0] == start_block 64 | 65 | 66 | def test_create_appointment_receipt_wrong_inputs(): 67 | sk = PrivateKey.from_int(42) 68 | data = get_random_value_hex(120) 69 | signature = Cryptographer.sign(data.encode("utf-8"), sk) 70 | start_block = 200 71 | overflow_iu4nt = pow(2, 32) 72 | 73 | no_str = [{}, [], None, 15, 4.5, dict(), object, True] 74 | no_int = [{}, [], None, "", 4.5, dict(), object] 75 | 76 | for wrong_param in no_str: 77 | with pytest.raises(InvalidParameter, match="user_signature is invalid"): 78 | receipts.create_appointment_receipt(wrong_param, start_block) 79 | for wrong_param in no_int: 80 | with pytest.raises(InvalidParameter, match="must be a 4-byte unsigned integer"): 81 | receipts.create_appointment_receipt(signature, wrong_param) 82 | 83 | # Same for overflow u4int 84 | with pytest.raises(InvalidParameter, match="start_block must be a 4-byte unsigned integer"): 85 | receipts.create_appointment_receipt(signature, overflow_iu4nt) 86 | -------------------------------------------------------------------------------- /test/common/unit/test_tools.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from common.constants import LOCATOR_LEN_BYTES 4 | from common.tools import ( 5 | is_compressed_pk, 6 | is_256b_hex_str, 7 | is_locator, 8 | compute_locator, 9 | setup_data_folder, 10 | is_u4int, 11 | intify, 12 | ) 13 | from test.common.unit.conftest import get_random_value_hex 14 | 15 | 16 | def test_is_compressed_pk(): 17 | wrong_values = [ 18 | None, 19 | 3, 20 | 15.23, 21 | "", 22 | {}, 23 | (), 24 | object, 25 | str, 26 | get_random_value_hex(32), 27 | get_random_value_hex(34), 28 | "06" + get_random_value_hex(32), 29 | ] 30 | 31 | # check_user_pk must only accept values that is not a 33-byte hex string 32 | for i in range(100): 33 | if i % 2: 34 | prefix = "02" 35 | else: 36 | prefix = "03" 37 | assert is_compressed_pk(prefix + get_random_value_hex(32)) 38 | 39 | # check_user_pk must only accept values that is not a 33-byte hex string 40 | for value in wrong_values: 41 | assert not is_compressed_pk(value) 42 | 43 | 44 | def test_is_256b_hex_str(): 45 | # Only 32-byte hex encoded strings should pass the test 46 | wrong_inputs = [None, str(), 213, 46.67, dict(), "A" * 63, "C" * 65, bytes(), get_random_value_hex(31)] 47 | for wtype in wrong_inputs: 48 | assert is_256b_hex_str(wtype) is False 49 | 50 | for v in range(100): 51 | assert is_256b_hex_str(get_random_value_hex(32)) is True 52 | 53 | 54 | def test_is_u4int(): 55 | out_of_range = [-1, pow(2, 32)] 56 | in_range = [0, pow(2, 32) // 2, pow(2, 32) - 1] 57 | wrong_inputs = [None, str(), 46.67, dict(), "A", bytes(), get_random_value_hex(31)] 58 | 59 | # Test ints out of the range return false 60 | for x in out_of_range: 61 | assert not is_u4int(x) 62 | 63 | # Same for wrong inputs 64 | for x in wrong_inputs: 65 | assert not is_u4int(x) 66 | 67 | # True is returned for values in range 68 | for x in in_range: 69 | assert is_u4int(x) 70 | 71 | 72 | def test_check_locator_format(): 73 | # Check that only LOCATOR_LEN_BYTES long string pass the test 74 | 75 | wrong_inputs = [ 76 | None, 77 | str(), 78 | 213, 79 | 46.67, 80 | dict(), 81 | "A" * (2 * LOCATOR_LEN_BYTES - 1), 82 | "C" * (2 * LOCATOR_LEN_BYTES + 1), 83 | bytes(), 84 | get_random_value_hex(LOCATOR_LEN_BYTES - 1), 85 | ] 86 | for wtype in wrong_inputs: 87 | assert is_locator(wtype) is False 88 | 89 | for _ in range(100): 90 | assert is_locator(get_random_value_hex(LOCATOR_LEN_BYTES)) is True 91 | 92 | 93 | def test_compute_locator(): 94 | # The best way of checking that compute locator is correct is by using is_locator 95 | for _ in range(100): 96 | assert is_locator(compute_locator(get_random_value_hex(LOCATOR_LEN_BYTES))) is True 97 | 98 | # String of length smaller than LOCATOR_LEN_BYTES bytes must fail 99 | for i in range(1, LOCATOR_LEN_BYTES): 100 | assert is_locator(compute_locator(get_random_value_hex(i))) is False 101 | 102 | 103 | def test_setup_data_folder(): 104 | # This method should create a folder if it does not exist, and do nothing otherwise 105 | test_folder = "test_folder" 106 | assert not os.path.isdir(test_folder) 107 | 108 | setup_data_folder(test_folder) 109 | 110 | assert os.path.isdir(test_folder) 111 | 112 | os.rmdir(test_folder) 113 | 114 | 115 | def test_intify_unchanged(): 116 | test_cases = [0, 1.1, True, False, "yo", {}, []] 117 | for x in test_cases: 118 | assert intify(x) == x 119 | 120 | res = intify([3, 2.5, "yo"]) 121 | assert res == [3, 2.5, "yo"] 122 | assert type(res[0]) == int 123 | assert type(res[1]) == float 124 | 125 | x = [3, 2.5, "yo", [4]] 126 | res = intify(x) 127 | assert res == x 128 | assert type(res[0]) == int 129 | assert type(res[1]) == float 130 | assert type(res[3][0]) == int 131 | 132 | x = {"a": 1, "b": 2.5, "c": {"d": [8]}} 133 | res = intify(x) 134 | assert res == x 135 | assert type(res["a"] == int) 136 | assert type(res["b"] == float) 137 | assert type(res["c"]["d"][0] == int) 138 | 139 | 140 | def test_intify_changed(): 141 | assert intify(1.0) == 1 142 | assert intify(-1.0) == -1 143 | 144 | x = [1.0, 1.5, -2.0, True] 145 | res = intify(x) 146 | assert res == [1, 1.5, -2, True] 147 | assert type(res[0]) == int 148 | assert type(res[1]) == float 149 | assert type(res[2]) == int 150 | assert type(res[3]) == bool 151 | 152 | x = {"a": 1.0, "b": 1.5, "c": -2.0, "d": False} 153 | res = intify(x) 154 | assert res == {"a": 1, "b": 1.5, "c": -2, "d": False} 155 | assert type(res["a"]) == int 156 | assert type(res["b"]) == float 157 | assert type(res["c"]) == int 158 | assert type(res["d"]) == bool 159 | 160 | x = {"a": 1.0, "b": [4, {"c": 5.0, "cc": 5.5}], "d": {"e": ["foo", 6, 6.5, 7.0]}} 161 | res = intify(x) 162 | assert res == {"a": 1, "b": [4, {"c": 5, "cc": 5.5}], "d": {"e": ["foo", 6, 6.5, 7]}} 163 | assert type(res["a"]) == int 164 | assert type(res["b"][0]) == int 165 | assert type(res["b"][1]["c"]) == int 166 | assert type(res["b"][1]["cc"]) == float 167 | assert type(res["d"]["e"][1]) == int 168 | assert type(res["d"]["e"][2]) == float 169 | assert type(res["d"]["e"][3]) == int 170 | -------------------------------------------------------------------------------- /test/teos/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/talaia-labs/python-teos/10d82c8588e4461a6db19bd27920914fc144fdd7/test/teos/__init__.py -------------------------------------------------------------------------------- /test/teos/bitcoin.conf: -------------------------------------------------------------------------------- 1 | # [network] 2 | dnsseed=0 3 | 4 | # [debug] 5 | daemon=1 6 | regtest=1 7 | debug=1 8 | 9 | # [rpc] 10 | server=1 11 | rpcuser=user 12 | rpcpassword=passwd 13 | 14 | # [blockchain] 15 | txindex=1 16 | 17 | # [zmq] 18 | zmqpubhashblock=tcp://127.0.0.1:28332 19 | zmqpubrawblock=tcp://127.0.0.1:28332 20 | zmqpubhashtx=tcp://127.0.0.1:28333 21 | zmqpubrawtx=tcp://127.0.0.1:28333 -------------------------------------------------------------------------------- /test/teos/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | import random 4 | import subprocess 5 | from time import sleep 6 | from os import makedirs 7 | from shutil import rmtree, copy 8 | from decimal import Decimal, getcontext 9 | 10 | from teos.teosd import get_config 11 | from teos.utils.auth_proxy import AuthServiceProxy, JSONRPCException 12 | 13 | 14 | getcontext().prec = 10 15 | utxos = list() 16 | btc_addr = None 17 | 18 | 19 | cmd_args = {"BTC_NETWORK": "regtest"} 20 | config = get_config(cmd_args, ".teos") 21 | 22 | bitcoin_cli = AuthServiceProxy( 23 | "http://%s:%s@%s:%d" 24 | % ( 25 | config.get("BTC_RPC_USER"), 26 | config.get("BTC_RPC_PASSWORD"), 27 | config.get("BTC_RPC_CONNECT"), 28 | config.get("BTC_RPC_PORT"), 29 | ) 30 | ) 31 | 32 | 33 | @pytest.fixture(scope="session", autouse=True) 34 | def prng_seed(): 35 | random.seed(0) 36 | 37 | 38 | @pytest.fixture(scope="session") 39 | def run_bitcoind(dirname=".test_bitcoin"): 40 | # Run bitcoind in a separate folder 41 | makedirs(dirname, exist_ok=True) 42 | 43 | bitcoind = os.getenv("BITCOIND", "bitcoind") 44 | 45 | copy(os.path.join(os.path.dirname(__file__), "bitcoin.conf"), dirname) 46 | subprocess.Popen([bitcoind, f"--datadir={dirname}"]) 47 | 48 | # Generate some initial blocks 49 | setup_node() 50 | yield 51 | 52 | bitcoin_cli.stop() 53 | rmtree(dirname) 54 | 55 | 56 | def setup_node(): 57 | global btc_addr 58 | 59 | # Check bitcoind is running while generating the address 60 | while True: 61 | # FIXME: Not creating a new bitcoin_cli here creates one of those Request-Sent errors I don't know how to fix 62 | # Not a big deal, but it would be nicer not having to. 63 | bitcoin_cli = AuthServiceProxy( 64 | "http://%s:%s@%s:%d" 65 | % ( 66 | config.get("BTC_RPC_USER"), 67 | config.get("BTC_RPC_PASSWORD"), 68 | config.get("BTC_RPC_CONNECT"), 69 | config.get("BTC_RPC_PORT"), 70 | ) 71 | ) 72 | try: 73 | info = bitcoin_cli.getnetworkinfo() 74 | 75 | if info.get("version") >= 210000: 76 | bitcoin_cli.createwallet("test_wallet") 77 | 78 | btc_addr = bitcoin_cli.getnewaddress() 79 | break 80 | 81 | except ConnectionError: 82 | sleep(1) 83 | except JSONRPCException as e: 84 | if "Loading wallet..." in str(e): 85 | sleep(1) 86 | 87 | # Mine enough blocks so coinbases are mature and we have enough funds to run everything 88 | bitcoin_cli.generatetoaddress(105, btc_addr) 89 | create_initial_transactions() 90 | 91 | 92 | def create_initial_transactions(fee=Decimal("0.00005")): 93 | utxos = bitcoin_cli.listunspent() 94 | btc_addresses = [bitcoin_cli.getnewaddress() for _ in range(100)] 95 | for utxo in utxos: 96 | # Create 100 outputs per utxo and mine a new block. 97 | tx_ins = {"txid": utxo.get("txid"), "vout": utxo.get("vout")} 98 | 99 | tx_outs = {btc_address: utxo.get("amount") / 100 for btc_address in btc_addresses[:-1]} 100 | tx_outs[btc_addresses[-1]] = (utxo.get("amount") / 100) - fee 101 | 102 | raw_tx = bitcoin_cli.createrawtransaction([tx_ins], tx_outs) 103 | signed_tx = bitcoin_cli.signrawtransactionwithwallet(raw_tx) 104 | bitcoin_cli.sendrawtransaction(signed_tx.get("hex")) 105 | 106 | bitcoin_cli.generatetoaddress(1, btc_addr) 107 | 108 | 109 | def get_random_value_hex(nbytes): 110 | pseudo_random_value = random.getrandbits(8 * nbytes) 111 | prv_hex = "{:x}".format(pseudo_random_value) 112 | return prv_hex.zfill(2 * nbytes) 113 | 114 | 115 | def get_utxo(): 116 | global utxos 117 | if not utxos: 118 | utxos = bitcoin_cli.listunspent() 119 | 120 | if len(utxos) == 0: 121 | raise ValueError("There are no UTXOs.") 122 | 123 | utxo = utxos.pop(0) 124 | while utxo.get("amount") < Decimal("0.00002"): 125 | utxo = utxos.pop(0) 126 | 127 | return utxo 128 | 129 | 130 | def mock_generate_blocks(n, blocks, queue, prev_block_hash=get_random_value_hex(32), txs=None, delay=0.2): 131 | if txs is not None and not isinstance(txs, list): 132 | raise ValueError("txs must be list or None") 133 | 134 | for i in range(n): 135 | block_id = get_random_value_hex(32) 136 | blocks[block_id] = { 137 | "previousblockhash": prev_block_hash, 138 | "tx": txs if txs else [get_random_value_hex(32) for _ in range(10)], 139 | "hash": block_id, 140 | } 141 | queue.put(block_id) 142 | prev_block_hash = block_id 143 | sleep(delay) 144 | 145 | 146 | def fork(block_hash, blocks): 147 | bitcoin_cli.invalidateblock(block_hash) 148 | bitcoin_cli.generatetoaddress(blocks, bitcoin_cli.getnewaddress()) 149 | 150 | 151 | def generate_blocks(n): 152 | return bitcoin_cli.generatetoaddress(n, btc_addr) 153 | 154 | 155 | def generate_blocks_with_delay(n, delay=0.2): 156 | block_ids = [] 157 | for _ in range(n): 158 | block_ids.extend(generate_blocks(1)) 159 | sleep(delay) 160 | 161 | return block_ids 162 | 163 | 164 | def generate_block_with_transactions(commitment_txs): 165 | # If a list of transactions is passed, send them all 166 | if isinstance(commitment_txs, list): 167 | for tx in commitment_txs: 168 | bitcoin_cli.sendrawtransaction(tx) 169 | elif isinstance(commitment_txs, str): 170 | bitcoin_cli.sendrawtransaction(commitment_txs) 171 | 172 | return generate_blocks(1) 173 | 174 | 175 | def create_commitment_tx(utxo=None, destination=None, fee=Decimal("0.00001")): 176 | if not utxo: 177 | utxo = get_utxo() 178 | 179 | # We will set the recipient to ourselves if destination is None 180 | if destination is None: 181 | destination = utxo.get("address") 182 | 183 | commitment_tx_ins = {"txid": utxo.get("txid"), "vout": utxo.get("vout")} 184 | commitment_tx_outs = {destination: utxo.get("amount") - fee} 185 | 186 | raw_commitment_tx = bitcoin_cli.createrawtransaction([commitment_tx_ins], commitment_tx_outs) 187 | signed_commitment_tx = bitcoin_cli.signrawtransactionwithwallet(raw_commitment_tx) 188 | 189 | if not signed_commitment_tx.get("complete"): 190 | raise ValueError("Couldn't sign transaction. {}".format(signed_commitment_tx)) 191 | 192 | return signed_commitment_tx.get("hex") 193 | 194 | 195 | def create_penalty_tx(decoded_commitment_tx, destination=None, fee=Decimal("0.00001")): 196 | # We will set the recipient to ourselves if destination is None 197 | if destination is None: 198 | destination = decoded_commitment_tx.get("vout")[0].get("scriptPubKey").get("addresses")[0] 199 | 200 | penalty_tx_ins = {"txid": decoded_commitment_tx.get("txid"), "vout": 0} 201 | penalty_tx_outs = {destination: decoded_commitment_tx.get("vout")[0].get("value") - fee} 202 | 203 | orphan_info = { 204 | "txid": decoded_commitment_tx.get("txid"), 205 | "scriptPubKey": decoded_commitment_tx.get("vout")[0].get("scriptPubKey").get("hex"), 206 | "vout": 0, 207 | "amount": decoded_commitment_tx.get("vout")[0].get("value"), 208 | } 209 | 210 | raw_penalty_tx = bitcoin_cli.createrawtransaction([penalty_tx_ins], penalty_tx_outs) 211 | signed_penalty_tx = bitcoin_cli.signrawtransactionwithwallet(raw_penalty_tx, [orphan_info]) 212 | 213 | if not signed_penalty_tx.get("complete"): 214 | raise ValueError("Couldn't sign orphan transaction. {}".format(signed_penalty_tx)) 215 | 216 | return signed_penalty_tx.get("hex") 217 | 218 | 219 | def create_txs(): 220 | signed_commitment_tx = create_commitment_tx() 221 | decoded_commitment_tx = bitcoin_cli.decoderawtransaction(signed_commitment_tx) 222 | 223 | signed_penalty_tx = create_penalty_tx(decoded_commitment_tx) 224 | 225 | return signed_commitment_tx, decoded_commitment_tx.get("txid"), signed_penalty_tx 226 | -------------------------------------------------------------------------------- /test/teos/e2e/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/talaia-labs/python-teos/10d82c8588e4461a6db19bd27920914fc144fdd7/test/teos/e2e/__init__.py -------------------------------------------------------------------------------- /test/teos/e2e/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import pytest 4 | from time import sleep 5 | import multiprocessing 6 | from grpc import RpcError 7 | from multiprocessing import Process 8 | 9 | from teos.teosd import main 10 | from teos.cli.teos_cli import RPCClient 11 | from common.cryptographer import Cryptographer 12 | from test.teos.conftest import config 13 | 14 | multiprocessing.set_start_method("spawn") 15 | 16 | 17 | # This fixture needs to be manually run on the first E2E. 18 | @pytest.fixture(scope="module") 19 | def teosd(run_bitcoind): 20 | teosd_process, teos_id = run_teosd() 21 | 22 | yield teosd_process, teos_id 23 | 24 | # FIXME: This is not ideal, but for some reason stop raises socket being closed on the first try here. 25 | stopped = False 26 | while not stopped: 27 | try: 28 | rpc_client = RPCClient(config.get("RPC_BIND"), config.get("RPC_PORT")) 29 | rpc_client.stop() 30 | stopped = True 31 | except RpcError: 32 | print("failed") 33 | pass 34 | 35 | teosd_process.join() 36 | shutil.rmtree(".teos", ignore_errors=True) 37 | 38 | # FIXME: wait some time, otherwise it might fail when multiple e2e tests are ran in the same session. Not sure why. 39 | sleep(1) 40 | 41 | 42 | def run_teosd(): 43 | sk_file_path = os.path.join(config.get("DATA_DIR"), "teos_sk.der") 44 | if not os.path.exists(sk_file_path): 45 | # Generating teos sk so we can return the teos_id 46 | teos_sk = Cryptographer.generate_key() 47 | Cryptographer.save_key_file(teos_sk.to_der(), "teos_sk", config.get("DATA_DIR")) 48 | else: 49 | teos_sk = Cryptographer.load_private_key_der(Cryptographer.load_key_file(sk_file_path)) 50 | 51 | teos_id = Cryptographer.get_compressed_pk(teos_sk.public_key) 52 | 53 | # Change the default WSGI for Windows 54 | if os.name == "nt": 55 | config["WSGI"] = "waitress" 56 | teosd_process = Process(target=main, kwargs={"config": config}) 57 | teosd_process.start() 58 | 59 | # Give it some time to bootstrap 60 | # TODO: we should do better synchronization using an Event 61 | sleep(3) 62 | 63 | return teosd_process, teos_id 64 | 65 | 66 | def build_appointment_data(commitment_tx_id, penalty_tx): 67 | appointment_data = {"tx": penalty_tx, "tx_id": commitment_tx_id, "to_self_delay": 20} 68 | 69 | return appointment_data 70 | -------------------------------------------------------------------------------- /test/teos/e2e/teos.conf: -------------------------------------------------------------------------------- 1 | [bitcoind] 2 | btc_rpc_user = user 3 | btc_rpc_password = passwd 4 | btc_rpc_connect = localhost 5 | btc_network = regtest 6 | btc_rpc_port = 18443 7 | 8 | [teos] 9 | max_appointments = 100 10 | expiry_delta = 6 11 | min_to_self_delay = 20 12 | 13 | # [chain monitor] 14 | polling_delta = 60 15 | block_window_size = 10 16 | -------------------------------------------------------------------------------- /test/teos/e2e/test_cli_e2e.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import json 3 | from time import sleep 4 | 5 | from contrib.client import teos_client 6 | 7 | from common.exceptions import InvalidParameter 8 | from common.cryptographer import Cryptographer 9 | 10 | from teos.cli.rpc_client import RPCClient 11 | 12 | from test.teos.conftest import ( 13 | create_txs, 14 | generate_block_with_transactions, 15 | generate_blocks, 16 | config, 17 | ) 18 | from test.teos.e2e.conftest import build_appointment_data 19 | 20 | teos_base_endpoint = "http://{}:{}".format(config.get("API_BIND"), config.get("API_PORT")) 21 | 22 | user_sk = Cryptographer.generate_key() 23 | user_id = Cryptographer.get_compressed_pk(user_sk.public_key) 24 | 25 | 26 | @pytest.fixture 27 | def rpc_client(): 28 | return RPCClient(config.get("RPC_BIND"), config.get("RPC_PORT")) 29 | 30 | 31 | def get_appointment_info(teos_id, locator, sk=user_sk): 32 | sleep(1) # Let's add a bit of delay so the state can be updated 33 | return teos_client.get_appointment(locator, sk, teos_id, teos_base_endpoint) 34 | 35 | 36 | def add_appointment(teos_id, appointment_data, sk=user_sk): 37 | return teos_client.add_appointment(appointment_data, sk, teos_id, teos_base_endpoint) 38 | 39 | 40 | def test_get_all_appointments(teosd, rpc_client): 41 | _, teos_id = teosd 42 | 43 | # Check that there is no appointment, so far 44 | all_appointments = json.loads(rpc_client.get_all_appointments()) 45 | watching = all_appointments.get("watcher_appointments") 46 | responding = all_appointments.get("responder_trackers") 47 | assert len(watching) == 0 and len(responding) == 0 48 | 49 | # Register a user 50 | teos_client.register(user_id, teos_id, teos_base_endpoint) 51 | 52 | # After that we can build an appointment and send it to the tower 53 | commitment_tx, commitment_txid, penalty_tx = create_txs() 54 | appointment_data = build_appointment_data(commitment_txid, penalty_tx) 55 | appointment = teos_client.create_appointment(appointment_data) 56 | add_appointment(teos_id, appointment) 57 | 58 | # Now there should now be one appointment in the watcher 59 | all_appointments = json.loads(rpc_client.get_all_appointments()) 60 | watching = all_appointments.get("watcher_appointments") 61 | responding = all_appointments.get("responder_trackers") 62 | assert len(watching) == 1 and len(responding) == 0 63 | 64 | # Trigger a breach and check again; now the appointment should be in the responder 65 | generate_block_with_transactions(commitment_tx) 66 | sleep(1) 67 | 68 | all_appointments = json.loads(rpc_client.get_all_appointments()) 69 | watching = all_appointments.get("watcher_appointments") 70 | responding = all_appointments.get("responder_trackers") 71 | assert len(watching) == 0 and len(responding) == 1 72 | 73 | # Now let's mine some blocks so the appointment reaches its end. We need 100 + EXPIRY_DELTA -1 74 | generate_blocks(100 + config.get("EXPIRY_DELTA")) 75 | sleep(1) 76 | 77 | # Now the appointment should not be in the tower, back to 0 78 | all_appointments = json.loads(rpc_client.get_all_appointments()) 79 | watching = all_appointments.get("watcher_appointments") 80 | responding = all_appointments.get("responder_trackers") 81 | assert len(watching) == 0 and len(responding) == 0 82 | 83 | 84 | def test_get_tower_info(teosd, rpc_client): 85 | tower_info = json.loads(rpc_client.get_tower_info()) 86 | assert set(tower_info.keys()) == set( 87 | ["n_registered_users", "tower_id", "n_watcher_appointments", "n_responder_trackers"] 88 | ) 89 | 90 | 91 | def test_get_users(teosd, rpc_client): 92 | _, teos_id = teosd 93 | 94 | # Create a fresh user 95 | tmp_user_id = Cryptographer.get_compressed_pk(Cryptographer.generate_key().public_key) 96 | 97 | users = json.loads(rpc_client.get_users()) 98 | assert tmp_user_id not in users 99 | 100 | # Register the fresh user 101 | teos_client.register(tmp_user_id, teos_id, teos_base_endpoint) 102 | 103 | users = json.loads(rpc_client.get_users()) 104 | assert tmp_user_id in users 105 | 106 | 107 | def test_get_user(teosd, rpc_client): 108 | _, teos_id = teosd 109 | 110 | # Register a user 111 | available_slots, subscription_expiry = teos_client.register(user_id, teos_id, teos_base_endpoint) 112 | 113 | # Get back its info 114 | user = json.loads(rpc_client.get_user(user_id)) 115 | 116 | assert set(user.keys()) == set(["appointments", "available_slots", "subscription_expiry"]) 117 | assert user["available_slots"] == available_slots 118 | assert user["subscription_expiry"] == subscription_expiry 119 | 120 | 121 | def test_get_user_non_existing(teosd, rpc_client): 122 | # Get a user that does not exist 123 | with pytest.raises(InvalidParameter): 124 | rpc_client.get_user("00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00") 125 | -------------------------------------------------------------------------------- /test/teos/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/talaia-labs/python-teos/10d82c8588e4461a6db19bd27920914fc144fdd7/test/teos/unit/__init__.py -------------------------------------------------------------------------------- /test/teos/unit/cli/test_rpc_client.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from teos.cli.rpc_client import RPCClient 4 | from common.exceptions import InvalidParameter 5 | 6 | test_host = "test" 7 | test_port = 4242 8 | 9 | 10 | @pytest.fixture 11 | def rpc_client(): 12 | return RPCClient(test_host, test_port) 13 | 14 | 15 | def test_get_user_invalid_user_id(rpc_client): 16 | with pytest.raises(InvalidParameter): 17 | rpc_client.get_user("1234") # invalid user_id 18 | -------------------------------------------------------------------------------- /test/teos/unit/cli/test_teos_cli.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | import grpc 4 | from unittest.mock import MagicMock 5 | 6 | from teos.cli.teos_cli import show_usage, CLI, CLICommand 7 | from common.exceptions import InvalidParameter 8 | 9 | 10 | @CLI.command 11 | class MockCommandRpcUnreachable(CLICommand): 12 | """ 13 | NAME: teos-cli mock_command_rpc_unreachable - A mock command that simulates a network error. 14 | """ 15 | 16 | name = "mock_command_rpc_unreachable" 17 | 18 | @staticmethod 19 | def run(rpc_client, opts_args): 20 | # RpcError does not define the code() method, so we mock it in. 21 | error = grpc.RpcError() 22 | error.code = lambda: grpc.StatusCode.UNAVAILABLE 23 | raise error 24 | 25 | 26 | @CLI.command 27 | class MockCommandRpcError(CLICommand): 28 | """ 29 | NAME: teos-cli mock_command_rpc_error - A mock command that simulates some other grpc error. 30 | """ 31 | 32 | name = "mock_command_rpc_error" 33 | 34 | @staticmethod 35 | def run(rpc_client, opts_args): 36 | # RpcError does not define the details() method, so we mock it in. 37 | error = grpc.RpcError() 38 | error.code = lambda: grpc.StatusCode.INTERNAL 39 | error.details = lambda: "error details" 40 | raise error 41 | 42 | 43 | @CLI.command 44 | class MockCommandInvalidParameter(CLICommand): 45 | """ 46 | NAME: teos-cli mock_command_invalid_parameter - A mock command that raises InvalidParameter. 47 | """ 48 | 49 | name = "mock_command_invalid_parameter" 50 | 51 | @staticmethod 52 | def run(rpc_client, opts_args): 53 | raise InvalidParameter("Invalid parameter") 54 | 55 | 56 | @CLI.command 57 | class MockCommandException(CLICommand): 58 | """ 59 | NAME: teos-cli mock_command_exception - A mock command that raises some other Exception. 60 | """ 61 | 62 | name = "mock_command_exception" 63 | 64 | @staticmethod 65 | def run(rpc_client, opts_args): 66 | raise Exception("Mock Exception") 67 | 68 | 69 | @pytest.fixture 70 | def cli(): 71 | cli = CLI(".teos-cli-test", {}) 72 | yield cli 73 | os.rmdir(".teos-cli-test") 74 | 75 | 76 | def test_show_usage_does_not_throw(): 77 | # If any of the Cli commands' docstring has a wrong format and cannot be parsed, this will raise an error 78 | show_usage() 79 | 80 | 81 | def test_cli_init_does_not_throw(): 82 | try: 83 | CLI(".teos-cli-test", {}) 84 | finally: 85 | os.rmdir(".teos-cli-test") 86 | 87 | 88 | def test_run_rpcerror_unavailable(cli, monkeypatch): 89 | assert "It was not possible to reach the Eye of Satoshi" in cli.run("mock_command_rpc_unreachable", []) 90 | 91 | 92 | def test_run_rpcerror_other(cli, monkeypatch): 93 | assert "error details" == cli.run("mock_command_rpc_error", []) 94 | 95 | 96 | def test_run_invalid_parameter(cli, monkeypatch): 97 | assert "Invalid parameter" in cli.run("mock_command_invalid_parameter", []) 98 | 99 | 100 | def test_run_exception(cli, monkeypatch): 101 | assert "Unknown error occurred: Mock Exception" == cli.run("mock_command_exception", []) 102 | 103 | 104 | def test_unknown_command_exits(cli): 105 | assert "Unknown command" in cli.run("this_command_probably_doesnt_exist", []) 106 | 107 | 108 | def test_stop(cli, monkeypatch): 109 | rpc_client_mock = MagicMock(cli.rpc_client) 110 | monkeypatch.setattr(cli, "rpc_client", rpc_client_mock) 111 | 112 | cli.run("stop", []) 113 | 114 | rpc_client_mock.stop.assert_called_once() 115 | 116 | 117 | def test_get_all_appointments(cli, monkeypatch): 118 | rpc_client_mock = MagicMock(cli.rpc_client) 119 | monkeypatch.setattr(cli, "rpc_client", rpc_client_mock) 120 | 121 | cli.run("get_all_appointments", []) 122 | 123 | rpc_client_mock.get_all_appointments.assert_called_once() 124 | 125 | 126 | def test_get_tower_info(cli, monkeypatch): 127 | rpc_client_mock = MagicMock(cli.rpc_client) 128 | monkeypatch.setattr(cli, "rpc_client", rpc_client_mock) 129 | 130 | cli.run("get_tower_info", []) 131 | 132 | rpc_client_mock.get_tower_info.assert_called_once() 133 | 134 | 135 | def test_get_user(cli, monkeypatch): 136 | rpc_client_mock = MagicMock(cli.rpc_client) 137 | monkeypatch.setattr(cli, "rpc_client", rpc_client_mock) 138 | 139 | assert "No user_id was given" in cli.run("get_user", []) 140 | assert "Expected only one argument, not 2" in cli.run("get_user", ["1", "2"]) 141 | 142 | # the previous calls should not have called the rpc client, since the arguments number was wrong 143 | cli.run("get_user", ["42"]) 144 | rpc_client_mock.get_user.assert_called_once_with("42") 145 | 146 | 147 | def test_get_users(cli, monkeypatch): 148 | rpc_client_mock = MagicMock(cli.rpc_client) 149 | monkeypatch.setattr(cli, "rpc_client", rpc_client_mock) 150 | 151 | cli.run("get_users", []) 152 | 153 | rpc_client_mock.get_users.assert_called_once() 154 | -------------------------------------------------------------------------------- /test/teos/unit/conftest.py: -------------------------------------------------------------------------------- 1 | import time 2 | import pytest 3 | import threading 4 | from copy import deepcopy 5 | from threading import Event 6 | from coincurve import PrivateKey 7 | 8 | from teos.responder import TransactionTracker 9 | from teos.block_processor import BlockProcessor 10 | from teos.extended_appointment import ExtendedAppointment 11 | from teos.internal_api import AuthenticationFailure, NotEnoughSlots 12 | 13 | from common.tools import compute_locator 14 | from common.exceptions import InvalidParameter 15 | from common.cryptographer import Cryptographer 16 | 17 | from test.teos.conftest import ( 18 | config, 19 | get_random_value_hex, 20 | ) 21 | 22 | 23 | from test.teos.unit.mocks import ( 24 | Gatekeeper as GatekeeperMock, 25 | BlockProcessor as BlockProcessorMock, 26 | AppointmentsDBM as AppointmentsDBManagerMock, 27 | UsersDBM as UserDBMMock, 28 | Carrier as CarrierMock, 29 | Responder as ResponderMock, 30 | ) 31 | 32 | 33 | bitcoind_connect_params = {k: v for k, v in config.items() if k.startswith("BTC")} 34 | wrong_bitcoind_connect_params = deepcopy(bitcoind_connect_params) 35 | wrong_bitcoind_connect_params["BTC_RPC_PORT"] = 1234 36 | bitcoind_feed_params = {k: v for k, v in config.items() if k.startswith("BTC_FEED")} 37 | bitcoind_reachable = Event() 38 | bitcoind_reachable.set() 39 | 40 | 41 | @pytest.fixture(scope="module") 42 | def block_processor(run_bitcoind): 43 | return BlockProcessor(bitcoind_connect_params, bitcoind_reachable) 44 | 45 | 46 | @pytest.fixture(scope="module") 47 | def block_processor_mock(): 48 | return BlockProcessorMock() 49 | 50 | 51 | @pytest.fixture 52 | def dbm_mock(): 53 | return AppointmentsDBManagerMock() 54 | 55 | 56 | @pytest.fixture(scope="module") 57 | def user_dbm_mock(): 58 | return UserDBMMock() 59 | 60 | 61 | @pytest.fixture(scope="module") 62 | def gatekeeper_mock(user_dbm_mock, block_processor_mock): 63 | return GatekeeperMock( 64 | user_dbm_mock, 65 | block_processor_mock, 66 | config.get("SUBSCRIPTION_SLOTS"), 67 | config.get("SUBSCRIPTION_DURATION"), 68 | config.get("EXPIRY_DELTA"), 69 | ) 70 | 71 | 72 | @pytest.fixture(scope="module") 73 | def carrier_mock(): 74 | return CarrierMock() 75 | 76 | 77 | @pytest.fixture(scope="module") 78 | def responder_mock(): 79 | return ResponderMock() 80 | 81 | 82 | @pytest.fixture(scope="session") 83 | def generate_dummy_appointment(): 84 | def _generate_dummy_appointment(): 85 | appointment_data = { 86 | "locator": get_random_value_hex(16), 87 | "to_self_delay": 20, 88 | "encrypted_blob": get_random_value_hex(150), 89 | "user_id": get_random_value_hex(16), 90 | "user_signature": get_random_value_hex(50), 91 | "start_block": 200, 92 | } 93 | 94 | return ExtendedAppointment.from_dict(appointment_data) 95 | 96 | return _generate_dummy_appointment 97 | 98 | 99 | @pytest.fixture(scope="session") 100 | def generate_dummy_appointment_w_trigger(): 101 | def _generate_dummy_appointment(): 102 | commitment_txid = get_random_value_hex(32) 103 | penalty_tx = get_random_value_hex(150) 104 | 105 | appointment_data = { 106 | "locator": compute_locator(commitment_txid), 107 | "to_self_delay": 20, 108 | "encrypted_blob": Cryptographer.encrypt(penalty_tx, commitment_txid), 109 | "user_id": get_random_value_hex(16), 110 | "user_signature": get_random_value_hex(50), 111 | "start_block": 200, 112 | } 113 | 114 | return ExtendedAppointment.from_dict(appointment_data), commitment_txid 115 | 116 | return _generate_dummy_appointment 117 | 118 | 119 | @pytest.fixture(scope="session") 120 | def generate_dummy_tracker(): 121 | def _generate_dummy_tracker(): 122 | tracker_data = dict( 123 | locator=get_random_value_hex(16), 124 | dispute_txid=get_random_value_hex(32), 125 | penalty_txid=get_random_value_hex(32), 126 | penalty_rawtx=get_random_value_hex(150), 127 | user_id="02" + get_random_value_hex(32), 128 | ) 129 | 130 | return TransactionTracker.from_dict(tracker_data) 131 | 132 | return _generate_dummy_tracker 133 | 134 | 135 | def generate_keypair(): 136 | sk = PrivateKey() 137 | pk = sk.public_key 138 | 139 | return sk, pk 140 | 141 | 142 | # Mocks the return of methods trying to query bitcoind while it cannot be reached 143 | def mock_connection_refused_return(*args, **kwargs): 144 | raise ConnectionRefusedError() 145 | 146 | 147 | def raise_invalid_parameter(*args, **kwargs): 148 | # Message is passed in the API response 149 | raise InvalidParameter("Invalid parameter message") 150 | 151 | 152 | def raise_auth_failure(*args, **kwargs): 153 | raise AuthenticationFailure("Auth failure msg") 154 | 155 | 156 | def raise_not_enough_slots(*args, **kwargs): 157 | raise NotEnoughSlots("") 158 | 159 | 160 | def set_bitcoind_reachable(bitcoind_reachable): 161 | # Sets the bitcoind_reachable event after a timeout so it can be used to tests the blocking functionality 162 | time.sleep(2) 163 | bitcoind_reachable.set() 164 | 165 | 166 | def run_test_command_bitcoind_crash(command): 167 | # Test without blocking 168 | with pytest.raises(ConnectionRefusedError): 169 | command() 170 | 171 | 172 | def run_test_blocking_command_bitcoind_crash(event, command): 173 | # Clear the lock and try it blocking using the valid BlockProcessor 174 | event.clear() 175 | t = threading.Thread(target=set_bitcoind_reachable, args=[event]) 176 | t.start() 177 | 178 | # This should not return an exception 179 | command() 180 | t.join() 181 | event.set() 182 | -------------------------------------------------------------------------------- /test/teos/unit/mocks.py: -------------------------------------------------------------------------------- 1 | from threading import Event 2 | 3 | from test.teos.conftest import get_random_value_hex 4 | from teos.appointments_dbm import WATCHER_PREFIX, WATCHER_LAST_BLOCK_KEY 5 | 6 | 7 | class BlockProcessor: 8 | """ A simple BlockProcessor mock""" 9 | 10 | def __init__(self, *args, **kwargs): 11 | self.bitcoind_reachable = Event() 12 | self.bitcoind_reachable.set() 13 | 14 | @staticmethod 15 | def get_block_count(*args, **kwargs): 16 | return 0 17 | 18 | @staticmethod 19 | def get_block(*args, **kwargs): 20 | return {"height": 0, "tx": []} 21 | 22 | @staticmethod 23 | def get_best_block_hash(*args, **kwargs): 24 | return get_random_value_hex(32) 25 | 26 | @staticmethod 27 | def decode_raw_transaction(*args, **kwargs): 28 | return {} 29 | 30 | def get_distance_to_tip(self, *args, **kwargs): 31 | pass 32 | 33 | 34 | class Carrier: 35 | """ A simple Carrier mock""" 36 | 37 | def __init__(self, *args, **kwargs): 38 | pass 39 | 40 | def send_transaction(self, *args, **kwargs): 41 | pass 42 | 43 | def get_transaction(self, *args, **kwargs): 44 | pass 45 | 46 | 47 | class Gatekeeper: 48 | """ A simple Gatekeeper mock""" 49 | 50 | def __init__(self, user_db, block_processor, *args, **kwargs): 51 | self.registered_users = dict() 52 | self.outdated_users_cache = {} 53 | self.user_db = user_db 54 | self.block_processor = block_processor 55 | 56 | @property 57 | def n_registered_users(self): 58 | return len(self.registered_users) 59 | 60 | def add_update_user(self, *args, **kwargs): 61 | pass 62 | 63 | def authenticate_user(self, *args, **kwargs): 64 | pass 65 | 66 | def has_subscription_expired(self, *args, **kwargs): 67 | pass 68 | 69 | def add_update_appointment(self, *args, **kwargs): 70 | pass 71 | 72 | def get_user_info(self, *args, **kwargs): 73 | pass 74 | 75 | def get_outdated_appointments(self, *args, **kwargs): 76 | pass 77 | 78 | def delete_appointments(self, *args, **kwargs): 79 | pass 80 | 81 | 82 | class Responder: 83 | """ A simple Responder mock""" 84 | 85 | def __init__(self, *args, **kwargs): 86 | self.trackers = {} 87 | 88 | def has_tracker(self, *args, **kwargs): 89 | pass 90 | 91 | def get_tracker(self, *args, **kwargs): 92 | pass 93 | 94 | def handle_breach(self, *args, **kwargs): 95 | pass 96 | 97 | 98 | class AppointmentsDBM: 99 | """ A mock that stores all the data related to appointments in memory instead of using a database""" 100 | 101 | def __init__(self): 102 | self.appointments = dict() 103 | self.trackers = dict() 104 | self.triggered_appointments = set() 105 | self.last_known_block_watcher = None 106 | self.last_known_block_responder = None 107 | self.data = dict() 108 | 109 | def load_appointments_db(self, prefix): 110 | if prefix == WATCHER_PREFIX: 111 | return self.appointments 112 | else: 113 | return self.trackers 114 | 115 | def get_last_known_block(self, key): 116 | if key == WATCHER_LAST_BLOCK_KEY: 117 | return self.last_known_block_watcher 118 | else: 119 | return self.last_known_block_responder 120 | 121 | def load_watcher_appointment(self, uuid): 122 | return self.appointments.get(uuid) 123 | 124 | def load_responder_tracker(self, uuid): 125 | return self.trackers.get(uuid) 126 | 127 | def load_watcher_appointments(self, include_triggered=False): 128 | appointments = self.appointments 129 | if not include_triggered: 130 | not_triggered = list(set(appointments.keys()).difference(self.triggered_appointments)) 131 | appointments = {uuid: appointments[uuid] for uuid in not_triggered} 132 | return appointments 133 | 134 | def load_responder_trackers(self): 135 | return self.trackers 136 | 137 | def store_watcher_appointment(self, uuid, appointment): 138 | self.appointments[uuid] = appointment 139 | 140 | def store_responder_tracker(self, uuid, tracker): 141 | self.trackers[uuid] = tracker 142 | 143 | def delete_watcher_appointment(self, uuid): 144 | del self.appointments[uuid] 145 | 146 | def batch_delete_watcher_appointments(self, uuids): 147 | for uuid in uuids: 148 | self.delete_watcher_appointment(uuid) 149 | 150 | def delete_responder_tracker(self, uuid): 151 | del self.trackers[uuid] 152 | 153 | def batch_delete_responder_trackers(self, uuids): 154 | for uuid in uuids: 155 | self.delete_responder_tracker(uuid) 156 | 157 | def load_last_block_hash_watcher(self): 158 | return self.last_known_block_watcher 159 | 160 | def load_last_block_hash_responder(self): 161 | return self.last_known_block_responder 162 | 163 | def store_last_block_hash_watcher(self, block_hash): 164 | self.last_known_block_watcher = block_hash 165 | 166 | def store_last_block_hash_responder(self, block_hash): 167 | self.last_known_block_responder = block_hash 168 | 169 | def create_triggered_appointment_flag(self, uuid): 170 | self.triggered_appointments.add(uuid) 171 | 172 | def batch_create_triggered_appointment_flag(self, uuids): 173 | self.triggered_appointments.update(uuids) 174 | 175 | def load_all_triggered_flags(self): 176 | return list(self.triggered_appointments) 177 | 178 | def delete_triggered_appointment_flag(self, uuid): 179 | self.triggered_appointments.remove(uuid) 180 | 181 | def batch_delete_triggered_appointment_flag(self, uuids): 182 | for uuid in uuids: 183 | self.delete_triggered_appointment_flag(uuid) 184 | 185 | 186 | class UsersDBM: 187 | """ A mock that stores all the data related to users in memory instead of using a database""" 188 | 189 | def __init__(self): 190 | self.users = dict() 191 | 192 | def store_user(self, user_id, user_data): 193 | self.users[user_id] = user_data 194 | 195 | def load_user(self, user_id): 196 | return self.users[user_id] 197 | 198 | def delete_user(self, user_id): 199 | del self.users[user_id] 200 | 201 | def load_all_users(self): 202 | return self.users 203 | -------------------------------------------------------------------------------- /test/teos/unit/test_builder.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from uuid import uuid4 3 | from queue import Queue 4 | 5 | from teos.builder import Builder 6 | from test.teos.unit.conftest import get_random_value_hex 7 | 8 | # FIXME: IMPROVE THE COMMENTS IN THIS SUITE 9 | 10 | 11 | def test_build_appointments(generate_dummy_appointment): 12 | # build_appointments builds two dictionaries: appointments (uuid:ExtendedAppointment) and locator_uuid_map 13 | # (locator:uuid). These are populated with data pulled from the database and used as initial state by the Watcher 14 | # during bootstrap 15 | appointments_data = {} 16 | 17 | # Create some appointment data 18 | for i in range(10): 19 | appointment = generate_dummy_appointment() 20 | uuid = uuid4().hex 21 | 22 | appointments_data[uuid] = appointment.to_dict() 23 | 24 | # Add some additional appointments that share the same locator to test all the builder's cases 25 | if i % 2 == 0: 26 | locator = appointment.locator 27 | appointment = generate_dummy_appointment() 28 | uuid = uuid4().hex 29 | appointment.locator = locator 30 | 31 | appointments_data[uuid] = appointment.to_dict() 32 | 33 | # Use the builder to create the data structures 34 | appointments, locator_uuid_map = Builder.build_appointments(appointments_data) 35 | 36 | # Check that the created appointments match the data 37 | for uuid, appointment in appointments.items(): 38 | assert uuid in appointments_data.keys() 39 | assert appointments_data[uuid].get("locator") == appointment.get("locator") 40 | assert appointments_data[uuid].get("user_id") == appointment.get("user_id") 41 | assert uuid in locator_uuid_map[appointment.get("locator")] 42 | 43 | 44 | def test_build_trackers(generate_dummy_tracker): 45 | # build_trackers builds two dictionaries: trackers (uuid: TransactionTracker) and tx_tracker_map (txid:uuid) 46 | # These are populated with data pulled from the database and used as initial state by the Responder during bootstrap 47 | trackers_data = {} 48 | 49 | # Create some trackers data 50 | for i in range(10): 51 | tracker = generate_dummy_tracker() 52 | 53 | trackers_data[uuid4().hex] = tracker.to_dict() 54 | 55 | # Add some additional trackers that share the same locator to test all the builder's cases 56 | if i % 2 == 0: 57 | penalty_txid = tracker.penalty_txid 58 | tracker = generate_dummy_tracker() 59 | tracker.penalty_txid = penalty_txid 60 | 61 | trackers_data[uuid4().hex] = tracker.to_dict() 62 | 63 | trackers, tx_tracker_map = Builder.build_trackers(trackers_data) 64 | 65 | # Check that the built trackers match the data 66 | for uuid, tracker in trackers.items(): 67 | assert uuid in trackers_data.keys() 68 | 69 | assert tracker.get("penalty_txid") == trackers_data[uuid].get("penalty_txid") 70 | assert tracker.get("locator") == trackers_data[uuid].get("locator") 71 | assert tracker.get("user_id") == trackers_data[uuid].get("user_id") 72 | assert uuid in tx_tracker_map[tracker.get("penalty_txid")] 73 | 74 | 75 | def test_populate_block_queue(): 76 | # populate_block_queue sets the initial state of the Watcher / Responder block queue 77 | 78 | # Create some random block hashes and construct the queue with them 79 | blocks = [get_random_value_hex(32) for _ in range(10)] 80 | queue = Queue() 81 | Builder.populate_block_queue(queue, blocks) 82 | 83 | # Make sure every block is in the queue and that there are not additional ones 84 | while not queue.empty(): 85 | block = queue.get() 86 | assert block in blocks 87 | blocks.remove(block) 88 | 89 | assert len(blocks) == 0 90 | 91 | 92 | def test_update_states_empty_list(): 93 | # update_states feed data to both the Watcher and the Responder block queue and waits until it is processed. It is 94 | # used to bring both components up to date during bootstrap. This is only used iof both have missed blocks, 95 | # otherwise populate_block_queue must be used. 96 | 97 | # Test the case where one of the components does not have any data to update with 98 | 99 | watcher_queue = Queue() 100 | responder_queue = Queue() 101 | missed_blocks_watcher = [] 102 | missed_blocks_responder = [get_random_value_hex(32)] 103 | 104 | # Any combination of empty list must raise a ValueError 105 | with pytest.raises(ValueError): 106 | Builder.update_states(watcher_queue, responder_queue, missed_blocks_watcher, missed_blocks_responder) 107 | 108 | with pytest.raises(ValueError): 109 | Builder.update_states(watcher_queue, responder_queue, missed_blocks_responder, missed_blocks_watcher) 110 | 111 | 112 | def test_update_states_responder_misses_more(monkeypatch): 113 | # Test the case where both components have data that need to be updated, but the Responder has more. 114 | blocks = [get_random_value_hex(32) for _ in range(5)] 115 | watcher_queue = Queue() 116 | responder_queue = Queue() 117 | 118 | # Monkeypatch so there's no join, since the queues are not tied to a Watcher and a Responder for the test 119 | monkeypatch.setattr(watcher_queue, "join", lambda: None) 120 | monkeypatch.setattr(responder_queue, "join", lambda: None) 121 | Builder.update_states(watcher_queue, responder_queue, blocks, blocks[1:]) 122 | 123 | assert responder_queue.queue.pop() == blocks[-1] 124 | assert watcher_queue.queue.pop() == blocks[-1] 125 | 126 | 127 | def test_update_states_watcher_misses_more(monkeypatch): 128 | # Test the case where both components have data that need to be updated, but the Watcher has more. 129 | blocks = [get_random_value_hex(32) for _ in range(5)] 130 | watcher_queue = Queue() 131 | responder_queue = Queue() 132 | 133 | # Monkeypatch so there's no join, since the queues are not tied to a Watcher and a Responder for the test 134 | monkeypatch.setattr(watcher_queue, "join", lambda: None) 135 | monkeypatch.setattr(responder_queue, "join", lambda: None) 136 | Builder.update_states(watcher_queue, responder_queue, blocks[1:], blocks) 137 | 138 | assert responder_queue.queue.pop() == blocks[-1] 139 | assert watcher_queue.queue.pop() == blocks[-1] 140 | -------------------------------------------------------------------------------- /test/teos/unit/test_carrier.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from threading import Event 3 | 4 | from teos.carrier import Carrier 5 | from teos.utils.rpc_errors import RPC_VERIFY_ALREADY_IN_CHAIN, RPC_DESERIALIZATION_ERROR 6 | 7 | from test.teos.conftest import generate_blocks, create_commitment_tx, bitcoin_cli 8 | from test.teos.unit.conftest import ( 9 | bitcoind_connect_params, 10 | get_random_value_hex, 11 | run_test_blocking_command_bitcoind_crash, 12 | ) 13 | 14 | 15 | # FIXME: #184-further-test-carrier: Add tests to cover all the errors that can be returned by bitcoind when pushing txs 16 | 17 | 18 | @pytest.fixture(scope="module") 19 | def carrier(run_bitcoind): 20 | bitcoind_reachable = Event() 21 | bitcoind_reachable.set() 22 | return Carrier(bitcoind_connect_params, bitcoind_reachable) 23 | 24 | 25 | sent_txs = [] 26 | 27 | 28 | def test_send_transaction(carrier): 29 | tx = create_commitment_tx() 30 | txid = bitcoin_cli.decoderawtransaction(tx).get("txid") 31 | 32 | receipt = carrier.send_transaction(tx, txid) 33 | 34 | assert receipt.delivered is True 35 | 36 | 37 | def test_send_double_spending_transaction(carrier): 38 | # We can test what happens if the same transaction is sent twice 39 | tx = create_commitment_tx() 40 | txid = bitcoin_cli.decoderawtransaction(tx).get("txid") 41 | 42 | receipt = carrier.send_transaction(tx, txid) 43 | sent_txs.append(txid) 44 | 45 | # Wait for a block to be mined. Issued receipts are reset from the Responder every block, so we should do it too. 46 | generate_blocks(2) 47 | carrier.issued_receipts = {} 48 | 49 | # Try to send it again 50 | receipt2 = carrier.send_transaction(tx, txid) 51 | 52 | # The carrier should report delivered True for both, but in the second case the transaction was already delivered 53 | # (either by himself or someone else) 54 | assert receipt.delivered is True 55 | assert receipt2.delivered is True and receipt2.confirmations >= 1 and receipt2.reason == RPC_VERIFY_ALREADY_IN_CHAIN 56 | 57 | 58 | def test_send_transaction_invalid_format(carrier): 59 | # Test sending a transaction that does not fits the format 60 | txid = create_commitment_tx()[::-1] 61 | receipt = carrier.send_transaction(txid, txid) 62 | 63 | assert receipt.delivered is False and receipt.reason == RPC_DESERIALIZATION_ERROR 64 | 65 | 66 | def test_get_transaction(carrier): 67 | # We should be able to get back every transaction we've sent 68 | for tx in sent_txs: 69 | tx_info = carrier.get_transaction(tx) 70 | 71 | assert tx_info is not None 72 | 73 | 74 | def test_get_non_existing_transaction(carrier): 75 | tx_info = carrier.get_transaction(get_random_value_hex(32)) 76 | 77 | assert tx_info is None 78 | 79 | 80 | # TESTS WITH BITCOIND UNREACHABLE 81 | 82 | 83 | def test_send_transaction_bitcoind_crash(carrier): 84 | # Trying to send a transaction if bitcoind is unreachable should block the thread until it becomes reachable again 85 | tx = create_commitment_tx() 86 | txid = bitcoin_cli.decoderawtransaction(tx).get("txid") 87 | 88 | run_test_blocking_command_bitcoind_crash( 89 | carrier.bitcoind_reachable, lambda: carrier.send_transaction(tx, txid), 90 | ) 91 | 92 | 93 | def test_get_transaction_bitcoind_crash(carrier): 94 | # Trying to get a transaction if bitcoind is unreachable should block the thread until it becomes reachable again 95 | run_test_blocking_command_bitcoind_crash( 96 | carrier.bitcoind_reachable, lambda: carrier.get_transaction(get_random_value_hex(32)), 97 | ) 98 | -------------------------------------------------------------------------------- /test/teos/unit/test_extended_appointment.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from teos.extended_appointment import ExtendedAppointment 4 | 5 | 6 | @pytest.fixture 7 | def ext_appointment_data(generate_dummy_appointment): 8 | return generate_dummy_appointment().to_dict() 9 | 10 | 11 | # Parent methods are not tested. 12 | 13 | 14 | def test_init_ext_appointment(ext_appointment_data): 15 | # The appointment has no checks whatsoever, since the inspector is the one taking care or that, and the only one 16 | # creating appointments. 17 | ext_appointment = ExtendedAppointment( 18 | ext_appointment_data["locator"], 19 | ext_appointment_data["encrypted_blob"], 20 | ext_appointment_data["to_self_delay"], 21 | ext_appointment_data["user_id"], 22 | ext_appointment_data["user_signature"], 23 | ext_appointment_data["start_block"], 24 | ) 25 | 26 | assert ( 27 | ext_appointment_data["locator"] == ext_appointment.locator 28 | and ext_appointment_data["to_self_delay"] == ext_appointment.to_self_delay 29 | and ext_appointment_data["encrypted_blob"] == ext_appointment.encrypted_blob 30 | and ext_appointment_data["user_id"] == ext_appointment.user_id 31 | and ext_appointment_data["user_signature"] == ext_appointment.user_signature 32 | and ext_appointment_data["start_block"] == ext_appointment.start_block 33 | ) 34 | 35 | 36 | def test_get_summary(ext_appointment_data): 37 | assert ExtendedAppointment.from_dict(ext_appointment_data).get_summary() == { 38 | "locator": ext_appointment_data["locator"], 39 | "user_id": ext_appointment_data["user_id"], 40 | } 41 | 42 | 43 | def test_from_dict(ext_appointment_data): 44 | # The appointment should be build if we don't miss any field 45 | ext_appointment = ExtendedAppointment.from_dict(ext_appointment_data) 46 | assert isinstance(ext_appointment, ExtendedAppointment) 47 | 48 | # Otherwise it should fail 49 | for key in ext_appointment_data.keys(): 50 | prev_val = ext_appointment_data[key] 51 | ext_appointment_data[key] = None 52 | 53 | with pytest.raises(ValueError, match="Wrong appointment data"): 54 | ExtendedAppointment.from_dict(ext_appointment_data) 55 | ext_appointment_data[key] = prev_val 56 | -------------------------------------------------------------------------------- /test/teos/unit/test_inspector.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import common.errors as errors 4 | from common.constants import LOCATOR_LEN_BYTES, LOCATOR_LEN_HEX 5 | 6 | from test.teos.conftest import config 7 | from teos.inspector import Inspector, InspectionFailed 8 | from test.teos.unit.conftest import get_random_value_hex 9 | 10 | NO_HEX_STRINGS = [ 11 | "R" * LOCATOR_LEN_HEX, 12 | get_random_value_hex(LOCATOR_LEN_BYTES - 1) + "PP", 13 | "$" * LOCATOR_LEN_HEX, 14 | " " * LOCATOR_LEN_HEX, 15 | ] 16 | WRONG_TYPES = [ 17 | [], 18 | "", 19 | get_random_value_hex(LOCATOR_LEN_BYTES), 20 | 3.2, 21 | 2.0, 22 | (), 23 | object, 24 | {}, 25 | " " * LOCATOR_LEN_HEX, 26 | object(), 27 | ] 28 | WRONG_TYPES_NO_STR = [[], bytes.fromhex(get_random_value_hex(LOCATOR_LEN_BYTES)), 3.2, 2.0, (), object, {}, object()] 29 | 30 | MIN_TO_SELF_DELAY = config.get("MIN_TO_SELF_DELAY") 31 | 32 | 33 | @pytest.fixture(scope="module") 34 | def inspector(): 35 | return Inspector(MIN_TO_SELF_DELAY) 36 | 37 | 38 | def test_check_locator(inspector): 39 | # Right appointment type, size and format 40 | locator = get_random_value_hex(LOCATOR_LEN_BYTES) 41 | assert inspector.check_locator(locator) is None 42 | 43 | # Wrong size (too big) 44 | locator = get_random_value_hex(LOCATOR_LEN_BYTES + 1) 45 | with pytest.raises(InspectionFailed): 46 | try: 47 | inspector.check_locator(locator) 48 | 49 | except InspectionFailed as e: 50 | assert e.errno == errors.APPOINTMENT_WRONG_FIELD_SIZE 51 | raise e 52 | 53 | # Wrong size (too small) 54 | locator = get_random_value_hex(LOCATOR_LEN_BYTES - 1) 55 | with pytest.raises(InspectionFailed): 56 | try: 57 | inspector.check_locator(locator) 58 | 59 | except InspectionFailed as e: 60 | assert e.errno == errors.APPOINTMENT_WRONG_FIELD_SIZE 61 | raise e 62 | 63 | # Empty 64 | locator = None 65 | with pytest.raises(InspectionFailed): 66 | try: 67 | inspector.check_locator(locator) 68 | 69 | except InspectionFailed as e: 70 | assert e.errno == errors.APPOINTMENT_EMPTY_FIELD 71 | raise e 72 | 73 | # Wrong type (several types tested, it should do for anything that is not a string) 74 | locators = [[], -1, 3.2, 0, 4, (), object, {}, object()] 75 | 76 | for locator in locators: 77 | with pytest.raises(InspectionFailed): 78 | try: 79 | inspector.check_locator(locator) 80 | 81 | except InspectionFailed as e: 82 | assert e.errno == errors.APPOINTMENT_WRONG_FIELD_TYPE 83 | raise e 84 | 85 | # Wrong format (no hex) 86 | locators = NO_HEX_STRINGS 87 | for locator in locators: 88 | with pytest.raises(InspectionFailed): 89 | try: 90 | inspector.check_locator(locator) 91 | 92 | except InspectionFailed as e: 93 | assert e.errno == errors.APPOINTMENT_WRONG_FIELD_FORMAT 94 | raise e 95 | 96 | 97 | def test_check_to_self_delay(inspector): 98 | # Right value, right format 99 | to_self_delays = [MIN_TO_SELF_DELAY, MIN_TO_SELF_DELAY + 1, MIN_TO_SELF_DELAY + 1000] 100 | for to_self_delay in to_self_delays: 101 | assert inspector.check_to_self_delay(to_self_delay) is None 102 | 103 | # to_self_delay too small 104 | to_self_delays = [MIN_TO_SELF_DELAY - 1, MIN_TO_SELF_DELAY - 2, 0, -1, -1000] 105 | for to_self_delay in to_self_delays: 106 | with pytest.raises(InspectionFailed): 107 | try: 108 | inspector.check_to_self_delay(to_self_delay) 109 | 110 | except InspectionFailed as e: 111 | assert e.errno == errors.APPOINTMENT_FIELD_TOO_SMALL 112 | raise e 113 | 114 | # Empty field 115 | to_self_delay = None 116 | with pytest.raises(InspectionFailed): 117 | try: 118 | inspector.check_to_self_delay(to_self_delay) 119 | 120 | except InspectionFailed as e: 121 | assert e.errno == errors.APPOINTMENT_EMPTY_FIELD 122 | raise e 123 | 124 | # Wrong data type 125 | to_self_delays = WRONG_TYPES 126 | for to_self_delay in to_self_delays: 127 | with pytest.raises(InspectionFailed): 128 | try: 129 | inspector.check_to_self_delay(to_self_delay) 130 | 131 | except InspectionFailed as e: 132 | assert e.errno == errors.APPOINTMENT_WRONG_FIELD_TYPE 133 | raise e 134 | 135 | 136 | def test_check_blob(inspector): 137 | # Right format and length 138 | encrypted_blob = get_random_value_hex(120) 139 | assert inspector.check_blob(encrypted_blob) is None 140 | 141 | # Wrong type 142 | encrypted_blobs = WRONG_TYPES_NO_STR 143 | for encrypted_blob in encrypted_blobs: 144 | with pytest.raises(InspectionFailed): 145 | try: 146 | inspector.check_blob(encrypted_blob) 147 | 148 | except InspectionFailed as e: 149 | assert e.errno == errors.APPOINTMENT_WRONG_FIELD_TYPE 150 | raise e 151 | 152 | # Empty field 153 | encrypted_blob = None 154 | with pytest.raises(InspectionFailed): 155 | try: 156 | inspector.check_blob(encrypted_blob) 157 | 158 | except InspectionFailed as e: 159 | assert e.errno == errors.APPOINTMENT_EMPTY_FIELD 160 | raise e 161 | 162 | # Wrong format (no hex) 163 | encrypted_blobs = NO_HEX_STRINGS 164 | for encrypted_blob in encrypted_blobs: 165 | with pytest.raises(InspectionFailed): 166 | try: 167 | inspector.check_blob(encrypted_blob) 168 | 169 | except InspectionFailed as e: 170 | assert e.errno == errors.APPOINTMENT_WRONG_FIELD_FORMAT 171 | raise e 172 | 173 | 174 | def test_inspect(inspector): 175 | # Valid appointment 176 | locator = get_random_value_hex(LOCATOR_LEN_BYTES) 177 | to_self_delay = MIN_TO_SELF_DELAY 178 | encrypted_blob = get_random_value_hex(64) 179 | 180 | appointment_data = {"locator": locator, "to_self_delay": to_self_delay, "encrypted_blob": encrypted_blob} 181 | 182 | appointment = inspector.inspect(appointment_data) 183 | 184 | assert ( 185 | appointment.locator == locator 186 | and appointment.to_self_delay == to_self_delay 187 | and appointment.encrypted_blob == encrypted_blob 188 | ) 189 | 190 | 191 | def test_inspect_wrong(inspector): 192 | # Wrong types (taking out empty dict, since that's a different error) 193 | wrong_types = WRONG_TYPES.pop(WRONG_TYPES.index({})) 194 | for data in wrong_types: 195 | with pytest.raises(InspectionFailed): 196 | try: 197 | inspector.inspect(data) 198 | except InspectionFailed as e: 199 | print(data) 200 | assert e.errno == errors.APPOINTMENT_WRONG_FIELD 201 | raise e 202 | 203 | # None data 204 | with pytest.raises(InspectionFailed): 205 | try: 206 | inspector.inspect(None) 207 | except InspectionFailed as e: 208 | assert e.errno == errors.APPOINTMENT_EMPTY_FIELD 209 | raise e 210 | -------------------------------------------------------------------------------- /test/teos/unit/test_logger.py: -------------------------------------------------------------------------------- 1 | from teos.logger import get_logger, encode_event_dict 2 | 3 | 4 | def test_get_logger(): 5 | # Test that get_logger actually adds a field called "component" with the expected value. 6 | # As the public interface of the class does not expose the initial_values, we rely on the output 7 | # of `repr` to check if the expected fields are indeed present. 8 | logger = get_logger("MyAwesomeComponent") 9 | assert "'component': 'MyAwesomeComponent'" in repr(logger) 10 | 11 | 12 | def test_encode_event_dict_with_event(): 13 | event_dict = {"event": "Test"} 14 | assert encode_event_dict(event_dict) == "Test" 15 | 16 | 17 | def test_encode_event_dict_with_event_and_timestamp(): 18 | event_dict = { 19 | "event": "Test", 20 | "timestamp": "today", # doesn't matter if it's not correct, should just copy it verbatim 21 | } 22 | assert encode_event_dict(event_dict) == "today Test" 23 | 24 | 25 | def test_encode_event_dict_with_event_and_timestamp_and_component(): 26 | event_dict = { 27 | "component": "MyAwesomeComponent", 28 | "event": "Test", 29 | "timestamp": "today", # doesn't matter if it's not correct, should just copy it verbatim 30 | } 31 | assert encode_event_dict(event_dict) == "today [MyAwesomeComponent] Test" 32 | 33 | 34 | def test_encode_event_dict_with_event_and_timestamp_and_component_and_extra_keys(): 35 | event_dict = { 36 | "component": "MyAwesomeComponent", 37 | "event": "Test", 38 | "timestamp": "today", # doesn't matter if it's not correct, should just copy it verbatim 39 | "key": 6, 40 | "aKeyBefore": 42, # should be rendered before "key", because it comes lexicographically before 41 | } 42 | assert encode_event_dict(event_dict) == "today [MyAwesomeComponent] Test (aKeyBefore=42, key=6)" 43 | -------------------------------------------------------------------------------- /test/teos/unit/test_tools.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from teos.tools import in_correct_network, get_default_rpc_port 4 | 5 | from common.constants import MAINNET_RPC_PORT, TESTNET_RPC_PORT, REGTEST_RPC_PORT 6 | 7 | from test.teos.unit.conftest import bitcoind_connect_params 8 | 9 | 10 | def test_in_correct_network(run_bitcoind): 11 | # The simulator runs as if it was regtest, so every other network should fail 12 | assert in_correct_network(bitcoind_connect_params, "mainnet") is False 13 | assert in_correct_network(bitcoind_connect_params, "testnet") is False 14 | assert in_correct_network(bitcoind_connect_params, "regtest") is True 15 | 16 | 17 | def test_get_default_rpc_port(): 18 | # Not much to be tested here. 19 | assert get_default_rpc_port("mainnet") is MAINNET_RPC_PORT 20 | assert get_default_rpc_port("testnet") is TESTNET_RPC_PORT 21 | assert get_default_rpc_port("regtest") is REGTEST_RPC_PORT 22 | 23 | 24 | def test_get_default_rpc_port_wrong(): 25 | values = [0, "", 1.3, dict(), object(), None, "fakenet"] 26 | 27 | for v in values: 28 | with pytest.raises(ValueError): 29 | get_default_rpc_port(v) 30 | -------------------------------------------------------------------------------- /test/teos/unit/test_users_dbm.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import shutil 3 | from teos.users_dbm import UsersDBM 4 | from teos.gatekeeper import UserInfo 5 | 6 | from test.teos.unit.conftest import get_random_value_hex 7 | 8 | 9 | @pytest.fixture 10 | def user_db_manager(db_name="test_user_db"): 11 | manager = UsersDBM(db_name) 12 | 13 | yield manager 14 | 15 | manager.db.close() 16 | shutil.rmtree(db_name) 17 | 18 | 19 | def test_store_user(user_db_manager): 20 | # Tests that users can be properly stored in the database 21 | 22 | # Store user should work as long as the user_pk is properly formatted and data is a dictionary 23 | user_id = "02" + get_random_value_hex(32) 24 | user_info = UserInfo(available_slots=42, subscription_expiry=100) 25 | 26 | assert user_db_manager.store_user(user_id, user_info.to_dict()) is True 27 | 28 | 29 | def test_store_user_wrong(user_db_manager): 30 | # Tests that trying to store wrong data will fail 31 | 32 | # Wrong pks should return False on adding 33 | user_id = "04" + get_random_value_hex(32) 34 | user_info = UserInfo(available_slots=42, subscription_expiry=100) 35 | assert user_db_manager.store_user(user_id, user_info.to_dict()) is False 36 | 37 | # Same for wrong types 38 | assert user_db_manager.store_user(42, user_info.to_dict()) is False 39 | 40 | # And for wrong type user data 41 | assert user_db_manager.store_user(user_id, 42) is False 42 | 43 | 44 | def test_load_user(user_db_manager): 45 | # Tests that loading a user should work, as long as the user is there 46 | 47 | # Add the user first 48 | user_id = "02" + get_random_value_hex(32) 49 | user_info = UserInfo(available_slots=42, subscription_expiry=100) 50 | user_db_manager.store_user(user_id, user_info.to_dict()) 51 | 52 | # Now load it 53 | assert user_db_manager.load_user(user_id) == user_info.to_dict() 54 | 55 | 56 | def test_load_user_wrong(user_db_manager): 57 | # Tests that wrong data won't load 58 | 59 | # Random keys should fail 60 | assert user_db_manager.load_user(get_random_value_hex(33)) is None 61 | 62 | # Wrong format keys should also return None 63 | assert user_db_manager.load_user(42) is None 64 | 65 | 66 | def test_delete_user(user_db_manager): 67 | # Tests that deleting existing users should work 68 | stored_users = {} 69 | 70 | # Add some users first 71 | for _ in range(10): 72 | user_id = "02" + get_random_value_hex(32) 73 | user_info = UserInfo(available_slots=42, subscription_expiry=100) 74 | user_db_manager.store_user(user_id, user_info.to_dict()) 75 | stored_users[user_id] = user_info 76 | 77 | # Deleting existing users should work 78 | for user_id, user_data in stored_users.items(): 79 | assert user_db_manager.delete_user(user_id) is True 80 | 81 | # There should be no users anymore 82 | assert not user_db_manager.load_all_users() 83 | 84 | 85 | def test_delete_user_wrong(user_db_manager): 86 | # Tests that deleting users with wrong data should fail 87 | 88 | # Non-existing user 89 | assert user_db_manager.delete_user(get_random_value_hex(32)) is True 90 | 91 | # Keys of wrong type 92 | assert user_db_manager.delete_user(42) is False 93 | 94 | 95 | def test_load_all_users(user_db_manager): 96 | # Tests loading all the users in the database 97 | stored_users = {} 98 | 99 | # There should be no users at the moment 100 | assert user_db_manager.load_all_users() == {} 101 | stored_users = {} 102 | 103 | # Adding some and checking we get them all 104 | for i in range(10): 105 | user_id = "02" + get_random_value_hex(32) 106 | user_info = UserInfo(available_slots=42, subscription_expiry=100) 107 | user_db_manager.store_user(user_id, user_info.to_dict()) 108 | stored_users[user_id] = user_info.to_dict() 109 | 110 | all_users = user_db_manager.load_all_users() 111 | assert all_users == stored_users 112 | -------------------------------------------------------------------------------- /watchtower-plugin/arg_parser.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from common.tools import is_compressed_pk, is_locator, is_256b_hex_str 4 | from common.exceptions import InvalidParameter 5 | 6 | 7 | def parse_register_arguments(tower_id, host, port, config): 8 | """ 9 | Parses the arguments of the register command and checks that they are correct. 10 | 11 | Args: 12 | tower_id (:obj:`str`): the identifier of the tower to connect to (a compressed public key). 13 | host (:obj:`str`): the ip or hostname to connect to, optional. 14 | host (:obj:`int`): the port to connect to, optional. 15 | config: (:obj:`dict`): the configuration dictionary. 16 | 17 | Returns: 18 | :obj:`tuple`: the tower id and tower network address. 19 | 20 | Raises: 21 | :obj:`common.exceptions.InvalidParameter`: if any of the parameters is wrong or missing. 22 | """ 23 | 24 | if not isinstance(tower_id, str): 25 | raise InvalidParameter(f"tower id must be a compressed public key (33-byte hex value) not {str(tower_id)}") 26 | 27 | # tower_id is of the form tower_id@[ip][:][port] 28 | if "@" in tower_id: 29 | if not (host and port): 30 | tower_id, tower_netaddr = tower_id.split("@") 31 | 32 | if not tower_netaddr: 33 | raise InvalidParameter("no tower endpoint was provided") 34 | 35 | # Only host was specified or colons where specified but not port 36 | if ":" not in tower_netaddr: 37 | tower_netaddr = f"{tower_netaddr}:{config.get('DEFAULT_PORT')}" 38 | elif tower_netaddr.endswith(":"): 39 | tower_netaddr = f"{tower_netaddr}{config.get('DEFAULT_PORT')}" 40 | 41 | else: 42 | raise InvalidParameter("cannot specify host as both xxx@yyy and separate arguments") 43 | 44 | # host was specified, but no port, defaulting 45 | elif host: 46 | tower_netaddr = f"{host}:{config.get('DEFAULT_PORT')}" 47 | 48 | # host and port specified 49 | elif host and port: 50 | tower_netaddr = f"{host}:{port}" 51 | 52 | else: 53 | raise InvalidParameter("tower host is missing") 54 | 55 | if not is_compressed_pk(tower_id): 56 | raise InvalidParameter("tower id must be a compressed public key (33-byte hex value)") 57 | 58 | return tower_id, tower_netaddr 59 | 60 | 61 | def parse_get_appointment_arguments(tower_id, locator): 62 | """ 63 | Parses the arguments of the get_appointment command and checks that they are correct. 64 | 65 | Args: 66 | tower_id (:obj:`str`): the identifier of the tower to connect to (a compressed public key). 67 | locator (:obj:`str`): the locator of the appointment to query the tower about. 68 | 69 | Returns: 70 | :obj:`tuple`: the tower id and appointment locator. 71 | 72 | Raises: 73 | :obj:`common.exceptions.InvalidParameter`: if any of the parameters is wrong or missing. 74 | """ 75 | 76 | if not is_compressed_pk(tower_id): 77 | raise InvalidParameter("tower id must be a compressed public key (33-byte hex value)") 78 | 79 | if not is_locator(locator): 80 | raise InvalidParameter("The provided locator is not valid", locator=locator) 81 | 82 | return tower_id, locator 83 | 84 | 85 | def parse_add_appointment_arguments(kwargs): 86 | """ 87 | Parses the arguments of the add_appointment command and checks that they are correct. 88 | 89 | The expected arguments are a commitment transaction id (32-byte hex string) and the penalty transaction. 90 | 91 | Args: 92 | kwargs (:obj:`dict`): a dictionary of arguments. 93 | 94 | Returns: 95 | :obj:`tuple`: the commitment transaction id and the penalty transaction. 96 | 97 | Raises: 98 | :obj:`common.exceptions.InvalidParameter`: if any of the parameters is wrong or missing. 99 | """ 100 | 101 | # Arguments to add_appointment come from c-lightning and they have been sanitised. Checking this just in case. 102 | commitment_txid = kwargs.get("commitment_txid") 103 | penalty_tx = kwargs.get("penalty_tx") 104 | 105 | if commitment_txid is None: 106 | raise InvalidParameter("missing required parameter: commitment_txid") 107 | 108 | if penalty_tx is None: 109 | raise InvalidParameter("missing required parameter: penalty_tx") 110 | 111 | if not is_256b_hex_str(commitment_txid): 112 | raise InvalidParameter("commitment_txid has invalid format") 113 | 114 | # Checking the basic stuff for the penalty transaction for now 115 | if type(penalty_tx) is not str or re.search(r"^[0-9A-Fa-f]+$", penalty_tx) is None: 116 | raise InvalidParameter("penalty_tx has invalid format") 117 | 118 | return commitment_txid, penalty_tx 119 | -------------------------------------------------------------------------------- /watchtower-plugin/keys.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | from pathlib import Path 3 | from coincurve import PrivateKey 4 | 5 | from common.exceptions import InvalidKey 6 | from common.cryptographer import Cryptographer 7 | 8 | 9 | def save_key(sk, filename): 10 | """ 11 | Saves the secret key on disk. 12 | 13 | Args: 14 | sk (:obj:`EllipticCurvePrivateKey`): a private key file to be saved on disk. 15 | filename (:obj:`str`): the name that will be given to the key file. 16 | """ 17 | 18 | with open(filename, "wb") as der_out: 19 | der_out.write(sk.to_der()) 20 | 21 | 22 | def generate_keys(data_dir): 23 | """ 24 | Generates a key pair for the client. 25 | 26 | Args: 27 | data_dir (:obj:`str`): path to data directory where the keys will be stored. 28 | 29 | Returns: 30 | :obj:`tuple`: a tuple containing a ``PrivateKey`` and a ``str`` representing the client sk and compressed pk 31 | respectively. 32 | 33 | Raises: 34 | :obj:`FileExistsError`: if the key pair already exists in the given directory. 35 | """ 36 | 37 | # Create the output folder it it does not exist (and all the parents if they don't either) 38 | Path(data_dir).mkdir(parents=True, exist_ok=True) 39 | sk_file_name = os.path.join(data_dir, "sk.der") 40 | 41 | if os.path.exists(sk_file_name): 42 | raise FileExistsError("The client key pair already exists") 43 | 44 | sk = PrivateKey() 45 | pk = sk.public_key 46 | save_key(sk, sk_file_name) 47 | 48 | return sk, Cryptographer.get_compressed_pk(pk) 49 | 50 | 51 | def load_keys(data_dir): 52 | """ 53 | Loads a the client key pair. 54 | 55 | Args: 56 | data_dir (:obj:`str`): path to data directory where the keys are stored. 57 | 58 | Returns: 59 | :obj:`tuple`: a tuple containing a ``PrivateKey`` and a ``str`` representing the client sk and compressed pk 60 | respectively. 61 | 62 | Raises: 63 | :obj:`InvalidKey `: if any of the keys is invalid or cannot be loaded. 64 | """ 65 | 66 | if not isinstance(data_dir, str): 67 | raise ValueError("Invalid data_dir. Please check your settings") 68 | 69 | sk_file_path = os.path.join(data_dir, "sk.der") 70 | 71 | cli_sk_der = Cryptographer.load_key_file(sk_file_path) 72 | cli_sk = Cryptographer.load_private_key_der(cli_sk_der) 73 | 74 | if cli_sk is None: 75 | raise InvalidKey("Client private key is invalid or cannot be parsed") 76 | 77 | compressed_cli_pk = Cryptographer.get_compressed_pk(cli_sk.public_key) 78 | 79 | if compressed_cli_pk is None: 80 | raise InvalidKey("Client public key cannot be loaded") 81 | 82 | return cli_sk, compressed_cli_pk 83 | -------------------------------------------------------------------------------- /watchtower-plugin/net/http.py: -------------------------------------------------------------------------------- 1 | import json 2 | import requests 3 | from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout, ReadTimeout, MissingSchema, InvalidSchema, InvalidURL 4 | 5 | from common import errors 6 | from common import constants 7 | import common.receipts as receipts 8 | from common.cryptographer import Cryptographer 9 | from common.exceptions import SignatureError, InvalidParameter, TowerConnectionError, TowerResponseError 10 | 11 | 12 | def add_appointment(plugin, tower_id, tower, appointment_dict, signature): 13 | try: 14 | plugin.log(f"Sending appointment {appointment_dict.get('locator')} to {tower_id}") 15 | response = send_appointment(tower_id, tower, appointment_dict, signature) 16 | plugin.log(f"Appointment accepted and signed by {tower_id}") 17 | plugin.log(f"Remaining slots: {response.get('available_slots')}") 18 | plugin.log(f"Start block: {response.get('start_block')}") 19 | 20 | # # TODO: Not storing the whole appointments for now. The node can recreate all the data if needed. 21 | # # DISCUSS: It may be worth checking that the available slots match instead of blindly trusting. 22 | return response.get("signature"), response.get("available_slots") 23 | 24 | except SignatureError as e: 25 | plugin.log(str(e)) 26 | plugin.log(f"{tower_id} is misbehaving, not using it any longer") 27 | raise e 28 | 29 | except TowerConnectionError as e: 30 | plugin.log(f"{tower_id} cannot be reached") 31 | 32 | raise e 33 | 34 | except TowerResponseError as e: 35 | data = e.kwargs.get("data") 36 | status_code = e.kwargs.get("status_code") 37 | 38 | if data and status_code == constants.HTTP_BAD_REQUEST: 39 | if data.get("error_code") == errors.APPOINTMENT_INVALID_SIGNATURE_OR_SUBSCRIPTION_ERROR: 40 | message = f"There is a subscription issue with {tower_id}" 41 | raise TowerResponseError(message, status="subscription error") 42 | 43 | elif data.get("error_code") >= errors.INVALID_REQUEST_FORMAT: 44 | message = f"Appointment sent to {tower_id} is invalid" 45 | raise TowerResponseError(message, status="reachable", invalid_appointment=True) 46 | 47 | elif status_code == constants.HTTP_SERVICE_UNAVAILABLE: 48 | # Flag appointment for retry 49 | message = f"{tower_id} is temporarily unavailable" 50 | 51 | raise TowerResponseError(message, status="temporarily unreachable") 52 | 53 | # Log unexpected behaviour without raising 54 | plugin.log(str(e), level="warn") 55 | 56 | 57 | def send_appointment(tower_id, tower, appointment_dict, signature): 58 | data = {"appointment": appointment_dict, "signature": signature} 59 | 60 | add_appointment_endpoint = f"{tower.netaddr}/add_appointment" 61 | response = process_post_response(post_request(data, add_appointment_endpoint, tower_id)) 62 | 63 | tower_signature = response.get("signature") 64 | start_block = response.get("start_block") 65 | 66 | if not tower_signature: 67 | raise SignatureError("The response does not contain the signature of the appointment") 68 | 69 | try: 70 | appointment_receipt = receipts.create_appointment_receipt(signature, start_block) 71 | except InvalidParameter as e: 72 | raise SignatureError( 73 | f"The receipt cannot be created. {e.msg}", 74 | tower_id=tower_id, 75 | recovered_id=None, 76 | signature=tower_signature, 77 | receipt=None, 78 | ) 79 | 80 | # Check that the server signed the receipt as it should. 81 | rpk = Cryptographer.recover_pk(appointment_receipt, tower_signature) 82 | recovered_id = Cryptographer.get_compressed_pk(rpk) 83 | if tower_id != recovered_id: 84 | raise SignatureError( 85 | "The returned appointment's signature is invalid", 86 | tower_id=tower_id, 87 | recovered_id=recovered_id, 88 | signature=tower_signature, 89 | receipt=appointment_receipt.hex(), 90 | ) 91 | 92 | return response 93 | 94 | 95 | def post_request(data, endpoint, tower_id): 96 | """ 97 | Sends a post request to the tower. 98 | 99 | Args: 100 | data (:obj:`dict`): a dictionary containing the data to be posted. 101 | endpoint (:obj:`str`): the endpoint to send the post request. 102 | tower_id (:obj:`str`): the identifier of the tower to connect to (a compressed public key). 103 | 104 | Returns: 105 | :obj:`dict`: a json-encoded dictionary with the server response if the data can be posted. 106 | 107 | Raises: 108 | :obj:`ConnectionError`: if the client cannot connect to the tower. 109 | """ 110 | 111 | try: 112 | return requests.post(url=endpoint, json=data, timeout=(6.10, 30)) 113 | 114 | except ConnectTimeout: 115 | message = f"Cannot connect to {tower_id}. Connection timeout" 116 | 117 | except ReadTimeout: 118 | message = f"Data cannot be read from {tower_id}. Read timeout" 119 | 120 | except ConnectionError: 121 | message = f"Cannot connect to {tower_id}. Tower cannot be reached" 122 | 123 | except (InvalidSchema, MissingSchema, InvalidURL): 124 | message = f"Invalid URL. No schema, or invalid schema, found (url={endpoint}, tower_id={tower_id})" 125 | 126 | raise TowerConnectionError(message) 127 | 128 | 129 | def process_post_response(response): 130 | """ 131 | Processes the server response to a post request. 132 | 133 | Args: 134 | response (:obj:`requests.models.Response`): a ``Response`` object obtained from the request. 135 | 136 | Returns: 137 | :obj:`dict`: a dictionary containing the tower's response data if the response type is 138 | ``HTTP_OK``. 139 | 140 | Raises: 141 | :obj:`TowerResponseError `: if the tower responded with an error, or the 142 | response was invalid. 143 | """ 144 | 145 | try: 146 | response_json = response.json() 147 | 148 | except (json.JSONDecodeError, AttributeError): 149 | raise TowerResponseError( 150 | "The server returned a non-JSON response", status_code=response.status_code, reason=response.reason 151 | ) 152 | 153 | if response.status_code not in [constants.HTTP_OK, constants.HTTP_NOT_FOUND]: 154 | raise TowerResponseError( 155 | "The server returned an error", status_code=response.status_code, reason=response.reason, data=response_json 156 | ) 157 | 158 | return response_json 159 | -------------------------------------------------------------------------------- /watchtower-plugin/requirements.txt: -------------------------------------------------------------------------------- 1 | pyln-client 2 | requests 3 | coincurve 4 | cryptography>=2.8 5 | pyzbase32 6 | plyvel 7 | backoff 8 | teos-common==0.1.1 9 | -------------------------------------------------------------------------------- /watchtower-plugin/retrier.py: -------------------------------------------------------------------------------- 1 | import backoff 2 | from threading import Thread 3 | 4 | from common.exceptions import SignatureError, TowerConnectionError, TowerResponseError 5 | 6 | from net.http import add_appointment 7 | 8 | 9 | MAX_RETRIES = None 10 | 11 | 12 | def check_retry(status): 13 | """ 14 | Checks is the job needs to be retried. Jobs are retried if max_retries is not reached and the tower status is 15 | temporarily unreachable. 16 | 17 | Args: 18 | status (:obj:`str`): the tower status. 19 | 20 | Returns: 21 | :obj:`bool`: True is the status is "temporarily unreachable", False otherwise. 22 | """ 23 | return status == "temporarily unreachable" 24 | 25 | 26 | def on_backoff(details): 27 | """ 28 | Function called when backing off after a retry. Logs data regarding the retry. 29 | Args: 30 | details: the retry details (check backoff library for more info). 31 | """ 32 | plugin = details.get("args")[1] 33 | tower_id = details.get("args")[2] 34 | plugin.log(f"Retry {details.get('tries')} failed for tower {tower_id}, backing off") 35 | 36 | 37 | def on_giveup(details): 38 | """ 39 | Function called when giving up after the last retry. Logs data regarding the retry and flags the tower as 40 | unreachable. 41 | 42 | Args: 43 | details: the retry details (check backoff library for more info). 44 | """ 45 | plugin = details.get("args")[1] 46 | tower_id = details.get("args")[2] 47 | 48 | plugin.log(f"Max retries reached, abandoning tower {tower_id}") 49 | 50 | tower_update = {"status": "unreachable"} 51 | plugin.wt_client.update_tower_state(tower_id, tower_update) 52 | 53 | 54 | def set_max_retries(max_retries): 55 | """Workaround to set max retries from Retrier to the backoff.on_predicate decorator""" 56 | global MAX_RETRIES 57 | MAX_RETRIES = max_retries 58 | 59 | 60 | def max_retries(): 61 | """Workaround to set max retries from Retrier to the backoff.on_predicate decorator""" 62 | return MAX_RETRIES 63 | 64 | 65 | class Retrier: 66 | """ 67 | The Retrier is in charge of the retry process for appointments that were sent to towers that were temporarily 68 | unreachable. 69 | 70 | Args: 71 | max_retries (:obj:`int`): the maximum number of times that a tower will be retried. 72 | temp_unreachable_towers (:obj:`Queue`): a queue of temporarily unreachable towers populated by the plugin on 73 | failing to deliver an appointment. 74 | """ 75 | 76 | def __init__(self, max_retries, temp_unreachable_towers): 77 | self.temp_unreachable_towers = temp_unreachable_towers 78 | set_max_retries(max_retries) 79 | 80 | def manage_retry(self, plugin): 81 | """ 82 | Listens to the temporarily unreachable towers queue and creates a thread to manage each tower it gets. 83 | 84 | Args: 85 | plugin (:obj:`Plugin`): the plugin object. 86 | """ 87 | 88 | while True: 89 | tower_id = self.temp_unreachable_towers.get() 90 | tower = plugin.wt_client.towers[tower_id] 91 | 92 | Thread(target=self.do_retry, args=[plugin, tower_id, tower], daemon=True).start() 93 | 94 | @backoff.on_predicate(backoff.expo, check_retry, max_tries=max_retries, on_backoff=on_backoff, on_giveup=on_giveup) 95 | def do_retry(self, plugin, tower_id, tower): 96 | """ 97 | Retries to send a list of pending appointments to a temporarily unreachable tower. This function is managed by 98 | manage_retries and run in a different thread per tower. 99 | 100 | For every pending appointment the worker thread tries to send the data to the tower. If the tower keeps being 101 | unreachable, the job is retries up to MAX_RETRIES. If MAX_RETRIES is reached, the worker thread gives up and the 102 | tower is flagged as unreachable. 103 | 104 | Args: 105 | plugin (:obj:`Plugin`): the plugin object. 106 | tower_id (:obj:`str`): the id of the tower managed by the thread. 107 | tower: (:obj:`TowerSummary`): the tower data. 108 | 109 | Returns: 110 | :obj:`str`: the tower status if it is not reachable. 111 | """ 112 | 113 | for appointment_dict, signature in plugin.wt_client.towers[tower_id].pending_appointments: 114 | tower_update = {} 115 | try: 116 | tower_signature, available_slots = add_appointment(plugin, tower_id, tower, appointment_dict, signature) 117 | tower_update["status"] = "reachable" 118 | tower_update["appointment"] = (appointment_dict.get("locator"), tower_signature) 119 | tower_update["available_slots"] = available_slots 120 | 121 | except SignatureError as e: 122 | tower_update["status"] = "misbehaving" 123 | tower_update["misbehaving_proof"] = { 124 | "appointment": appointment_dict, 125 | "signature": e.kwargs.get("signature"), 126 | "recovered_id": e.kwargs.get("recovered_id"), 127 | "receipt": e.kwargs.get("receipt"), 128 | } 129 | 130 | except TowerConnectionError: 131 | tower_update["status"] = "temporarily unreachable" 132 | 133 | except TowerResponseError as e: 134 | tower_update["status"] = e.kwargs.get("status") 135 | 136 | if e.kwargs.get("invalid_appointment"): 137 | tower_update["invalid_appointment"] = (appointment_dict, signature) 138 | 139 | if tower_update["status"] in ["reachable", "misbehaving"]: 140 | tower_update["pending_appointment"] = ([appointment_dict, signature], "remove") 141 | 142 | if tower_update["status"] != "temporarily unreachable": 143 | # Update memory and TowersDB 144 | plugin.wt_client.update_tower_state(tower_id, tower_update) 145 | 146 | # Continue looping if reachable, return for either retry or stop otherwise 147 | if tower_update["status"] != "reachable": 148 | return tower_update.get("status") 149 | -------------------------------------------------------------------------------- /watchtower-plugin/template.conf: -------------------------------------------------------------------------------- 1 | [teos] 2 | api_port = 9814 3 | max_retries = 30 4 | 5 | -------------------------------------------------------------------------------- /watchtower-plugin/tower_info.py: -------------------------------------------------------------------------------- 1 | class TowerInfo: 2 | """ 3 | TowerInfo represents all the data the plugin holds about a tower. 4 | 5 | Args: 6 | netaddr (:obj:`str`): the tower network address. 7 | available_slots (:obj:`int`): the amount of available appointment slots in the tower. 8 | status (:obj:`str`): the tower status. The tower can be in the following status: 9 | reachable: if the tower can be reached. 10 | temporarily unreachable: if the tower cannot be reached but the issue is transitory. 11 | unreachable: if the tower cannot be reached and the issue has persisted long enough, or it is permanent. 12 | subscription error: if there has been a problem with the subscription (e.g: run out of slots). 13 | misbehaving: if the tower has been caught misbehaving (e.g: an invalid signature has been received). 14 | 15 | Attributes: 16 | appointments (:obj:`dict`): a collection of accepted appointments. 17 | pending_appointments (:obj:`list`): a collection of pending appointments. Appointments are pending when the 18 | tower is unreachable or the subscription has expired / run out of slots. 19 | invalid_appointments (:obj:`list`): a collection of invalid appointments. Appointments are invalid if the tower 20 | rejects them for not following the proper format. 21 | misbehaving_proof (:obj:`dict`): a proof of misbehaviour from the tower. The tower is abandoned if so. 22 | """ 23 | 24 | def __init__(self, netaddr, available_slots, status="reachable"): 25 | self.netaddr = netaddr 26 | self.available_slots = available_slots 27 | self.status = status 28 | 29 | self.appointments = {} 30 | self.pending_appointments = [] 31 | self.invalid_appointments = [] 32 | self.misbehaving_proof = {} 33 | 34 | @classmethod 35 | def from_dict(cls, tower_data): 36 | """ 37 | Builds a TowerInfo object from a dictionary. 38 | 39 | Args: 40 | tower_data (:obj:`dict`): a dictionary containing all the TowerInfo fields. 41 | 42 | Returns: 43 | :obj:`TowerInfo`: A TowerInfo object built with the provided data. 44 | 45 | Raises: 46 | :obj:`ValueError`: If any of the expected fields is missing in the dictionary. 47 | """ 48 | 49 | netaddr = tower_data.get("netaddr") 50 | available_slots = tower_data.get("available_slots") 51 | status = tower_data.get("status") 52 | appointments = tower_data.get("appointments") 53 | pending_appointments = tower_data.get("pending_appointments") 54 | invalid_appointments = tower_data.get("invalid_appointments") 55 | misbehaving_proof = tower_data.get("misbehaving_proof") 56 | 57 | if any( 58 | v is None 59 | for v in [netaddr, available_slots, status, appointments, pending_appointments, invalid_appointments] 60 | ): 61 | raise ValueError("Wrong appointment data, some fields are missing") 62 | 63 | tower = cls(netaddr, available_slots, status) 64 | tower.appointments = appointments 65 | tower.pending_appointments = pending_appointments 66 | tower.invalid_appointments = invalid_appointments 67 | tower.misbehaving_proof = misbehaving_proof 68 | 69 | return tower 70 | 71 | def to_dict(self): 72 | """ 73 | Builds a dictionary from a TowerInfo object. 74 | 75 | Returns: 76 | :obj:`dict`: The TowerInfo object as a dictionary. 77 | """ 78 | return self.__dict__ 79 | 80 | def get_summary(self): 81 | """ 82 | Gets a summary of the TowerInfo object. 83 | 84 | The plugin only stores the minimal information in memory, the rest is dumped into the DB. Data kept in memory 85 | is stored in TowerSummary objects. 86 | 87 | Returns: 88 | :obj:`dict`: The summary of the TowerInfo object. 89 | """ 90 | return TowerSummary(self) 91 | 92 | 93 | class TowerSummary: 94 | """ 95 | A smaller representation of the TowerInfo data to be kept in memory. 96 | 97 | Args: 98 | tower_info(:obj:`TowerInfo`): A TowerInfo object. 99 | 100 | Attributes: 101 | netaddr (:obj:`str`): the tower network address. 102 | status (:obj:`str`): the status of the tower. 103 | available_slots (:obj:`int`): the amount of available appointment slots in the tower. 104 | pending_appointments (:obj:`list`): the collection of pending appointments. 105 | invalid_appointments (:obj:`list`): the collection of invalid appointments. 106 | """ 107 | 108 | def __init__(self, tower_info): 109 | self.netaddr = tower_info.netaddr 110 | self.status = tower_info.status 111 | self.available_slots = tower_info.available_slots 112 | self.pending_appointments = tower_info.pending_appointments 113 | self.invalid_appointments = tower_info.invalid_appointments 114 | 115 | def to_dict(self): 116 | """ 117 | Builds a dictionary from a TowerSummary object. 118 | 119 | Returns: 120 | :obj:`dict`: The TowerSummary object as a dictionary. 121 | """ 122 | 123 | return self.__dict__ 124 | -------------------------------------------------------------------------------- /watchtower-plugin/towers_dbm.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from common.db_manager import DBManager 4 | from common.tools import is_compressed_pk 5 | 6 | 7 | class TowersDBM(DBManager): 8 | """ 9 | The :class:`TowersDBM` is in charge of interacting with the towers database (``LevelDB``). 10 | Keys and values are stored as bytes in the database but processed as strings by the manager. 11 | 12 | Args: 13 | db_path (:obj:`str`): the path (relative or absolute) to the system folder containing the database. A fresh 14 | database will be created if the specified path does not contain one. 15 | 16 | Raises: 17 | :obj:`ValueError`: If the provided ``db_path`` is not a string. 18 | :obj:`plyvel.Error`: If the db is currently unavailable (being used by another process). 19 | """ 20 | 21 | def __init__(self, db_path, plugin): 22 | if not isinstance(db_path, str): 23 | raise ValueError("db_path must be a valid path/name") 24 | 25 | super().__init__(db_path) 26 | self.plugin = plugin 27 | 28 | def store_tower_record(self, tower_id, tower_data): 29 | """ 30 | Stores a tower record to the database. ``tower_id`` is used as identifier. 31 | 32 | Args: 33 | tower_id (:obj:`str`): a 33-byte hex-encoded string identifying the tower. 34 | tower_data (:obj:`dict`): the tower associated data, as a dictionary. 35 | 36 | Returns: 37 | :obj:`bool`: True if the tower record was stored in the database, False otherwise. 38 | """ 39 | 40 | if is_compressed_pk(tower_id): 41 | try: 42 | self.create_entry(tower_id, json.dumps(tower_data.to_dict())) 43 | self.plugin.log(f"Adding tower to Tower's db (id={tower_id})") 44 | return True 45 | 46 | except (json.JSONDecodeError, TypeError): 47 | self.plugin.log( 48 | f"Couldn't add tower to db. Wrong tower data format (tower_id={tower_id}, " 49 | f"tower_data={tower_data.to_dict()})" 50 | ) 51 | return False 52 | 53 | else: 54 | self.plugin.log( 55 | f"Couldn't add user to db. Wrong pk format (tower_id={tower_id}, tower_data={tower_data.to_dict()})" 56 | ) 57 | return False 58 | 59 | def load_tower_record(self, tower_id): 60 | """ 61 | Loads a tower record from the database using the ``tower_id`` as identifier. 62 | 63 | Args: 64 | 65 | tower_id (:obj:`str`): a 33-byte hex-encoded string identifying the tower. 66 | 67 | Returns: 68 | :obj:`dict`: A dictionary containing the tower data if the ``key`` is found. 69 | 70 | Returns ``None`` otherwise. 71 | """ 72 | 73 | try: 74 | data = self.load_entry(tower_id) 75 | data = json.loads(data) 76 | except (TypeError, json.decoder.JSONDecodeError): 77 | data = None 78 | 79 | return data 80 | 81 | def delete_tower_record(self, tower_id): 82 | """ 83 | Deletes a tower record from the database. 84 | 85 | Args: 86 | tower_id (:obj:`str`): a 33-byte hex-encoded string identifying the tower. 87 | 88 | Returns: 89 | :obj:`bool`: True if the tower was deleted from the database or it was non-existent, False otherwise. 90 | """ 91 | 92 | try: 93 | self.delete_entry(tower_id) 94 | self.plugin.log(f"Deleting tower from Tower's db (id={tower_id})") 95 | return True 96 | 97 | except TypeError: 98 | self.plugin.log(f"Cannot delete user from db, user key has wrong type (id={tower_id})") 99 | return False 100 | 101 | def load_all_tower_records(self): 102 | """ 103 | Loads all tower records from the database. 104 | 105 | Returns: 106 | :obj:`dict`: A dictionary containing all tower records indexed by ``tower_id``. 107 | 108 | Returns an empty dictionary if no data is found. 109 | """ 110 | 111 | data = {} 112 | 113 | for k, v in self.db.iterator(): 114 | # Get uuid and appointment_data from the db 115 | tower_id = k.decode("utf-8") 116 | data[tower_id] = json.loads(v) 117 | 118 | return data 119 | --------------------------------------------------------------------------------