Compare commits

...

272 commits

Author SHA1 Message Date
Omer Yacine
be344ecc52
Merge pull request #266 from hashrelay/flake
Add nix flake.

This allow to build the project executables with nix.
2025-10-09 12:06:06 +02:00
Joseph Goulden
3e652eeb28
Merge branch 'master' into flake 2025-10-08 22:13:53 +01:00
Joseph Goulden
70ea8b5625
Add nix flake to build teos with nixpkgs 25.05 2025-10-08 22:12:40 +01:00
oyindamola oladapo
4824cb3372
ci: fix and simplify cln-plugin job
- Correct YAML indentation
- Fix Poetry installation (no --user, avoids not found errors)
- Remove unnecessary steps and sudo usage
- Modularize CLN cache jobs
- Run `poetry run make` concurrently to speed up build
2025-10-08 22:10:16 +01:00
Omer Yacine
ce4dafcab8
Merge pull request #275 from hashrelay/https
Don't add an extra `http://` to a host name that already includes `https://` (secure http).
2025-09-27 19:50:44 +03:00
Joseph Goulden
3fd4f41325
Allow watchtower client to call https endoints 2025-09-27 15:29:37 +01:00
Omer Yacine
316b0c1d87
Merge pull request #272 from 0xZaddyy/fix/yaml-indent-cln-plugin
fix: correct YAML indentation in cln-plugin job step
2025-09-12 14:05:37 +03:00
oyindamola oladapo
469e247ac3
ci: fix and simplify cln-plugin job
- Correct YAML indentation
- Fix Poetry installation (no --user, avoids not found errors)
- Remove unnecessary steps and sudo usage
- Modularize CLN cache jobs
- Run `poetry run make` concurrently to speed up build
2025-09-12 11:00:16 +01:00
Omer Yacine
42db02157e
Merge pull request #268 from dzdidi/feat/deps-update
Feat/deps update
2025-04-01 16:30:34 +02:00
dzdidi
b3a621a781
Update dependencies
- Add toolchain
- Common:
  - bitcoin v0.32.0;
  - lightning v0.1.0
- TEOS:
  - bitcoin v0.32.0;
  - bitcoincore-rpc v0.19.0;
  - lightning v0.1.0;
  - lightning-net-tokio v0.1.0;
  - lightning-block-sync v0.1.0
- Watchtower-Plugin:
  - bitcoin v0.32.0;
  - cln-plugin v0.3.0
2025-03-25 12:06:21 +01:00
Sergi Delgado
4e47db227e
Merge pull request #265 from sr-gi/clippy-inspect-error
plugin: replaces map_err for inspect_err in cases where we were only logging and returning
2024-09-05 17:35:29 -04:00
Sergi Delgado Segura
cc0d18393d
plugin: replaces map_err for inspect_err in cases where we were only logging and returning 2024-09-05 17:19:12 -04:00
Sergi Delgado
9248f3a2b9
Merge pull request #264 from sr-gi/cln-cargo
Makes `time` is up to date when compiling CLN for CI jobs
2024-09-05 17:18:50 -04:00
Sergi Delgado Segura
1684571808
Makes sure the time create is up to date
Compiling time with rustc over 1.80.0 (inclusive) results in a crash
for old versions of the time crate, which CLN uses. Make sure we are up
to date with the current revision
2024-09-05 17:09:59 -04:00
Sergi Delgado
b8a996e4f0
Merge pull request #256 from vincenzopalazzo/master
meta: add possibility to install cln plugin with coffee
2024-09-05 17:09:03 -04:00
Vincenzo Palazzo
011ba645bb meta: add possibility to install cln plugin with coffee
Core lightning is starting to support a decent plugin manager that gives you the possibility to install a plugin without care about the process.

Coffee [1] is a plugin manager that uses the manifest like all basic plugin managers (npm, pacman ...).

So this commit is adding support for coffee and allowing people to install plugins with the following command:

```
coffee remote add teos-git https://github.com/talaia-labs/rust-teos.git
coffee install rust-teos -v
coffee list
```

[1] https://github.com/coffee-tools/coffee
Signed-off-by: Vincenzo Palazzo <vincenzopalazzodev@gmail.com>
2024-09-01 13:53:19 +02:00
Sergi Delgado
609da2dc23
Merge pull request #248 from sr-gi/bump-rcgen
Bumps dependencies to get rid old version of ring (<0.17)
2024-07-29 16:46:56 -04:00
Sergi Delgado Segura
2bf359582d
Adds protoc GH actions to watchtower-plugin job 2024-07-29 16:32:19 -04:00
Sergi Delgado Segura
81a659c0f6
Bumps tonic to 0.11 and prost to 0.12, adapts Cargo files accordingly
Bumping tonic required cargo file edition to be bumped to 2021
2024-07-29 16:27:32 -04:00
Sergi Delgado Segura
bb2df6a700
Bumps rcgen to version 0.13.1 and updates tls.rs accordingly
rcgen was using version 0.8 which used a ring version that didn't support
ppc64le arch. Support for it was recently added in ring=0.17.0 and supported by
rcgen in 0.13
2024-07-29 16:27:29 -04:00
Sergi Delgado
9d0d3af398
Merge pull request #263 from sr-gi/202407-clippy
Fixes clippy issues
2024-07-29 13:21:26 -04:00
Sergi Delgado Segura
d30fc7d121
Fixes clippy issues 2024-07-29 13:12:27 -04:00
Sergi Delgado
e1085104eb
Merge pull request #259 from daywalker90/modernize-cln-tests
modernize cln tests and CI
2024-07-29 12:51:34 -04:00
daywalker90
f6decde5ff
modernize cln tests and CI 2024-07-29 16:28:19 +02:00
Sergi Delgado
c9f82bce9f
Merge pull request #245 from sr-gi/fix_crypto_utils
teos-common: Fixes cryptography tests utils
2024-04-02 09:42:37 +02:00
Sergi Delgado Segura
95f1c4c21d
Fixes cryptography tests utils 2024-03-28 12:58:59 +01:00
Sergi Delgado
997f77f695
Merge pull request #249 from sr-gi/2024-01-authcookie
Adds rpccookie to bitcoind auth methods
2024-03-28 12:55:53 +01:00
Sergi Delgado Segura
37a75ec6e7
Adds rpccookie to bitcoind auth methods
Currently we are authenticating against bitcoind using user/pass. This adds
the option to use a cookie file instead.
2024-03-28 10:09:30 +01:00
Sergi Delgado
21acbe0185
Merge pull request #253 from sr-gi/202403-update-workflows
gh-actions: updates actions to use Node.js 20
2024-03-28 10:07:43 +01:00
Sergi Delgado Segura
99dd5bbd15
gh-actions: updates actions to use Node.js 20 2024-03-28 10:00:49 +01:00
Sergi Delgado
56ede5383c
Merge pull request #251 from sr-gi/2024-03-clippy-patches
Fixes clippy issues in dbm.rs
2024-03-28 09:51:25 +01:00
Sergi Delgado Segura
52ebbe6e06
Fixes clippy issues in dbm.rs 2024-03-28 09:41:55 +01:00
Sergi Delgado
7ea92bbdd5
Merge pull request #252 from sr-gi/fix-watchtower-tests-deps
watchtower-plugin: fixes pyln-client version
2024-03-28 09:41:18 +01:00
Sergi Delgado Segura
534d6390ba
watchtower-plugin: fixes pyln-client version
`pyln-testing` depends on `pyln-client`, and the API for the latter has changed
in version 24.0. Fix our dependency to 23.11 to prevent test from breaking
2024-03-28 09:30:29 +01:00
Sergi Delgado
a515118eea
Merge pull request #250 from sr-gi/2024-01-patch-clippy
plugin: patches clippy
2024-03-20 15:32:35 -04:00
Sergi Delgado Segura
e6495c31aa
Fixes clippy issues
Clippy is complaining about using `.get(0)` instead of `.fist()` in methods
where we are getting more than just the first item. Suppress those warning.

Also fixes some actual issues.
2024-01-08 14:18:59 -05:00
Sergi Delgado Segura
a4accedfee
Merge pull request #227 from orbitalturtle/docker
Dockerfile and instructions
2023-09-14 16:14:47 -04:00
Orbital
ddd4bd9ef1
Docker: Add README describing how to use Docker with teos 2023-09-14 14:54:51 -05:00
Orbital
96efd4320c
Docker: Add Dockerfile for running teos 2023-09-14 14:54:50 -05:00
Sergi Delgado Segura
658fcca0ce
Merge pull request #190 from mariocynicys/better-mem-cpu-usage
optimizing memory usage
2023-09-14 13:11:15 -04:00
Omer Yacine
1790fe31e0
Make random appointments variable in size 2023-09-01 16:39:54 +03:00
Omer Yacine
cab6151ccc
Getting rid of in-memory data to avoid unbounded memory growth
Regrading the `Watcher`, fields (appointments, locator_uuid_map) has
been replaced by DB calls when needed.

For `Responder`, the field `trackers` has been replaced by DB calls when
needed, and `tx_tracker_map` wasn't actually needed for the tower to
operate, so was just dropped.

For `GateKeeper`, `registered_users::appointments` which used to hold
the uuids of every appointment the user submitted was removed so that
`registered_users` only holds meta information about users.

Also now the gatekeeper is the entity responsible for deleting appointments from the database. Instead of the watcher/responder asking the gatekeeper for the users to update and carry out the deletion and update itself, now the watcher/responder will hand the gatekeeper the uuids to delete and the gatekeeper will figure out which users it needs to update (refund the freed slots to).

Also now, like in `Watcher::store_triggered_appointment`, if the appointment is invalid or was rejected by the network in block connections, the freed slots will not be refunded to the user.

Also the block connection order starts with the gatekeeper first, this
allows the gatekeeper to delete the outdated users so that the watcher
and the responder doesn't take them into account.
2023-09-01 16:39:54 +03:00
Omer Yacine
fad3ad1c08
dropping last_known_blocks after using it
`last_known_blocks` was taking up ~300migs of memory (for 100 blocks) because it was not dropped in `main`.

Co-authored-by: Sergi Delgado Segura <sergi.delgado.s@gmail.com>
2023-09-01 16:31:54 +03:00
Omer Yacine
8df58f99d1
loading minimal data during bootstrap
By loading the minimal necessary data during bootstrap, we get lower
memory usage and faster bootstrapping.

Co-authored-by: Sergi Delgado Segura <sergi.delgado.s@gmail.com>
2023-09-01 16:31:50 +03:00
Sergi Delgado Segura
6cc466c359
Merge pull request #233 from anipaul2/issue230
Add black check to Python files in CI
2023-08-21 09:23:38 +02:00
anipaul2
092f549f7e
Use black for .py files formatting 2023-08-11 02:13:57 +05:30
Sergi Delgado Segura
6cbc8df165
Merge pull request #225 from optimm/exception-handling/teos-cli
Fix error handling in CLI
2023-08-10 13:21:27 -04:00
optimm
565eda42ba
printing the cli errors to standard error stream
Added a function that prints the error to the standard error stream and exits the process with a status code of 1, this is to seprate the cli errors from regular output
2023-08-09 11:37:55 +05:30
Sergi Delgado Segura
bacac072e9
Merge pull request #231 from sr-gi/2023-07-clippy
Fixes clippy issues, updates warp
2023-07-24 11:26:32 -04:00
Sergi Delgado Segura
ed78c89613
Fixes clippy issues, updates warp
Fixes some clippy issues with rustc 1.71.0 and updates warp to fix some address
some warnings regarding `buf_redux v0.8.4` and `multipart v0.18.0`
2023-07-24 11:05:27 -04:00
Sergi Delgado Segura
59c770b25b
Merge pull request #220 from sr-gi/test-patch-sendrawtransaction
Properly patches sendrawtransaction so it does not crash
2023-07-24 11:04:55 -04:00
Sergi Delgado Segura
7093882f47
Properly patches sendrawtransaction so it does not crash
Currently, when mocking sendrawtransaction so it does nothing, the client will raise an exception
given the picked lambda does not accept any params (and sendrawtransaction has some). Patches it
so this does not happen.

This patch is pretty minimal, given the behavior wrt CLN does not change, but it feels better not to
except here.
2023-07-18 14:08:00 -04:00
Sergi Delgado Segura
1a89c5da70
Merge pull request #216 from sr-gi/outdated_tower
Adds --forceupdate to teosd
2023-05-09 11:02:37 -04:00
Sergi Delgado Segura
2bf555c07f
Adds --forceupdate to teosd
If a tower hasn't been running for a long time and the backend runs in pruned
mode it could be the case that by the time the tower comes back online, the Last
known block by the tower is not being known by the node anymore. In this situation,
the tower cannot bootstrap normally, given the cache cannot be populated.

This commits adds a new argument to teosd (`--forceupdate`) that can be used to
force a tower to update its last known block to the earliest known block by the backend
under this situation. Notice that doing so may make the tower miss some of its state
transitions (the ones triggered by missed blocks), so this must be done as a last resource.
2023-05-09 09:49:11 -04:00
Sergi Delgado Segura
7e0f0f0981
Merge pull request #211 from sr-gi/improve-contributing
Adds some notes on commits and GitHub to CONTRIBUTING
2023-05-09 09:48:29 -04:00
Sergi Delgado Segura
7fc30577ac
Adds some notes on commits and GitHub to CONTRIBUTING 2023-05-09 09:11:07 -04:00
Sergi Delgado Segura
6caf452daf
Merge pull request #213 from sr-gi/fix-cli-error
Makes teos-cli cannot connect to backend error message more friendly
2023-05-09 08:55:06 -04:00
Sergi Delgado Segura
c0f4ebc82c
Makes teos-cli cannot connect to backend error message more friendly
Let's not just spit a debug error when calling `teos-cli` pointing to an unreachable backend.
2023-05-08 21:53:56 -04:00
Sergi Delgado Segura
d1edc98a85
Merge pull request #200 from anmode/pingpong
[Feature] Implements a ping endpoint (http)
2023-05-02 15:57:06 -04:00
Anmol Agrawal
2229251702
Implements ping/pong logic for the tower and CLN plugin
Implements ping/pong logic for the tower and CLN plugin
Modification in test as suggested and some more

Some fixed

Signed-off-by: Anmol Agrawal <anmol2002.aa1@gmail.com>
2023-05-02 07:49:24 +05:30
Sergi Delgado Segura
94624fbe0b
Merge pull request #215 from anipaul2/master
Improve log messages for data directory and configuration details
2023-05-01 16:23:16 -04:00
anipaul2
ca577c558b
Improve log messages for data directory and configuration details
This commit enhances the logging output to provide clearer information
about the data directory and configuration file details. By providing
this information, it improves the user experience and make it easier for
users to find and understand the relevant paths and settings.
2023-05-02 01:36:19 +05:30
Sergi Delgado Segura
886e0fffe5
Merge pull request #206 from sr-gi/fix-clippy-responder
Removes unnecessary `into_iter` from Responder tests
2023-03-14 10:36:28 +01:00
Sergi Delgado Segura
801ef5d808
Removes unnecessary into_iter from Responder tests
The conversion was triggering https://rust-lang.github.io/rust-clippy/master/index.html#useless_conversion
2023-03-13 12:54:02 +01:00
Sergi Delgado Segura
67aa345de4
Merge pull request #194 from sr-gi/replace-httpmock
Replaces httpmock with mockito
2023-03-13 12:34:21 +01:00
Sergi Delgado Segura
7dc3fcd2cc
Replaces httpmock with mockito
Also:

- Replaced time based waits for loops with the expected conditions to
get given the former is way more error prone.
- Updates tests that needed a response based on the request (main reason why
    were're switching to mockito)
2023-03-08 12:11:49 +01:00
Sergi Delgado Segura
b60299c881
Merge pull request #196 from sr-gi/http_api_constructor
Makes http::serve take a SockAddr instead of a starting
2023-03-08 10:03:41 +01:00
Sergi Delgado Segura
939d87c0f0
Makes http::serve take a SocketAddr instead of a String
`PublicTowerServicesClient::connect` needs `dest` to be `std::convert::TryInto<tonic::transport::Endpoint>`
hence why we were passing a `String`. However, we'll be better of passing something that's `Copy`, like
`SocketAddr` and converting to `String` here.
2023-02-24 22:37:55 +01:00
Sergi Delgado Segura
076c972913
Merge pull request #189 from sr-gi/badges
Adds social and build badges
2023-02-20 12:19:42 +01:00
Sergi Delgado Segura
f63fd14184
Removes WIP, adds social, build and version badges 2023-02-20 11:52:00 +01:00
Sergi Delgado Segura
df948753d3
Merge pull request #181 from sr-gi/sqlite-fixes
Replaces Result<T, E> for Option<T> in DBM load methods
2023-02-20 11:51:25 +01:00
Sergi Delgado Segura
f628b358db
Replaces Result<T, E> for Option<T> in DBM load methods
The `DBM` methods related to loading data return `Result<T, E>` where `E` is always `dbm::Error::NotFound`.

It may makes more sense make them return `Option<T>`.
2023-02-20 11:26:38 +01:00
Sergi Delgado Segura
43f9971315
Merge pull request #185 from sr-gi/clippy-inline
Formats strings to use inline params when possible
2023-02-06 23:08:06 +01:00
Sergi Delgado Segura
4bcfbf7c5d
Formats strings to use inline params when possible 2023-02-06 22:56:09 +01:00
Sergi Delgado Segura
125709be65
Merge pull request #188 from mariocynicys/correct-onion-port
fixup: record the correct port for the Tor interface
2023-02-06 22:54:48 +01:00
Omer Yacine
75db9c6519
fixup: record the correct port for the Tor interface 2023-02-06 18:18:40 +02:00
Sergi Delgado Segura
280e4785f1
Merge pull request #183 from sr-gi/update-service
Updates teosd.service
2023-02-06 16:34:51 +01:00
Sergi Delgado Segura
403044298f
Updates teosd.service, adds tor link 2023-02-06 16:15:09 +01:00
Sergi Delgado Segura
0cf3d97523
Merge pull request #184 from sr-gi/retrier-waits
Defines Retrier polling waiting time as a constant and fixes tests
2023-02-06 11:01:00 +01:00
Sergi Delgado Segura
73a9bff9fa
Defines Retrier polling waiting time as a constant and fixes tests
The pooling time for the Retrier was hardcoded to 1, let's at least use a constant for that.

Also, `retrier::tests::test_manage_retry_while_idle` was randomly failing (for Ubuntu) when checking whether the Retrier was
idle after giving up on a retry. This is due to the time of running a round not being taken into account.
2023-01-27 11:11:00 -05:00
Sergi Delgado Segura
4ce986e0be
Merge pull request #179 from sr-gi/bump-version
Bumps teos version to 0.2 in preparation for code release
2023-01-23 17:58:46 -05:00
Sergi Delgado Segura
5cf9029fe9
Bumps teos version to 0.2 in preparation for code release 2023-01-23 17:45:20 -05:00
Sergi Delgado Segura
a0e2978b8b
Merge pull request #177 from sr-gi/log-rpc-requests
Adds debug logs to RPC requests and improves the ones for the http interface
2023-01-23 17:44:46 -05:00
Sergi Delgado Segura
72b1805c1b
Adds logs to RPC requests and improves the ones for the http interface
Also updates the severity from the http logs from info to debug
2023-01-23 17:44:29 -05:00
Sergi Delgado Segura
d386e56754
Merge pull request #176 from sr-gi/teos-service
Adds teosd.service to run teos as daemon
2023-01-23 17:43:48 -05:00
Sergi Delgado Segura
eda7b263e6
Adds teosd.service to run teos as daemon
This builds on top of the work from the work by @decentralizedb and @jochemin.
It is mainly just re-arranging the code and readmes to fit the reviews.

Co-authored-by: decentralizedb <decentralized.b@protonmail.com>
Co-authored-by: jochemin <jochemin@gmail.com>
2023-01-23 17:33:40 -05:00
Sergi Delgado Segura
4e0fca3c0b
Merge pull request #178 from sr-gi/wt-client-defaults
Remove unused defaults, document new ones and updates defaults
2023-01-23 14:29:58 -05:00
Sergi Delgado Segura
f18237bb3e
Remove unused defaults, document new ones and updates defaults
- `watchtower-proxy` was still part of constants but it was not being used anymore
- `watchtower-auto-retry-delay` was not part of the README

Also redefines defaults so a retry strategy lasts at most 1h, every interval is at most 15 minutes
and automatic retries are triggered every 8 hours.
2023-01-23 13:15:05 -05:00
Sergi Delgado Segura
85b40603f7
Merge pull request #175 from sr-gi/readmes
Updates readmes
2023-01-20 20:43:07 +01:00
Sergi Delgado Segura
197ae93534
Updates readmes 2023-01-20 14:42:20 -05:00
Sergi Delgado Segura
b035a3e460
Merge pull request #173 from sr-gi/endpoints
Defines Endpoint enum to avoid using hardcoded strings as endpoints
2023-01-20 17:21:23 +01:00
Sergi Delgado Segura
44df8bb6f3
Defines Endpoint enum to avoid using hardcoded strings as endpoints
Useful for both the tower API and the clients
2023-01-20 08:55:22 -05:00
Sergi Delgado Segura
19b3ad86f0
Merge pull request #160 from sr-gi/cln-tor-flag
Improves tor flag logic in watchtower-plugin
2023-01-20 14:50:31 +01:00
Sergi Delgado Segura
d50ce639bf
Improves tor flag logic in watchtower-plugin
The `watchtower-plugin` was specifying a custom tor flag to signal whether Tor may be used by the client. This was due to `cln-plugin (v0.1.1-)` not allowing plugins to access the CoreLN configuration options and, therefore, our plugin was unable to fetch the `proxy` / `always-use-proxy` options.

This fetches the aforementioned options and revamps the logic to comply with the `always-use-proxy` requirements, that is, if the flag is set all communications must be performed using Tor. Also, it replaces some of the currently used `String`s for more meaningful types to store network data (such as `AddressType`, `NetAddress`, or `ProxyInfo`).

This drops our custom `watchtower-proxy` config option
2023-01-20 08:37:03 -05:00
Sergi Delgado Segura
2a57a388bb
Merge pull request #170 from sr-gi/cln-simplify-options
Simplifies the cln plugin option parsing
2023-01-11 11:45:53 +01:00
Sergi Delgado Segura
1a076f92ea
Fixes auto-retry-delay type
`auto-retry-delay` was set to be u16, but its default value was beyond u16::MAX.
2023-01-11 11:27:35 +01:00
Sergi Delgado Segura
6920c9bf98
Simplifies the cln plugin option parsing
Options include helper functions to convert them to their proper type after
`cln-plugin=0.1.2`. Use that to reduce the option parsing boilerplate.

Also move all names, descriptions and default values for options, rpc_methods and hooks
to a new file.
2023-01-10 15:26:42 +01:00
Sergi Delgado Segura
e918223a39
Merge pull request #172 from sr-gi/cln-plugin-v0.1.2
Bumps cln-plugin version to v0.1.2 in watchtower-plugin
2023-01-10 14:26:13 +01:00
Sergi Delgado Segura
c3b735dec7
Bumps cln-plugin version to v0.1.2 in watchtower-plugin 2023-01-10 14:05:26 +01:00
Sergi Delgado Segura
cd39c6b43e
Merge pull request #169 from sr-gi/patch-test_get_appointment-e2e
Patches watchtower-plugin test_get_appointment e2e
2023-01-10 14:04:30 +01:00
Sergi Delgado Segura
31defe24f7
Patches watchtower-plugin test_get_appointment e2e
test_get_appointment fails when the plugin is asked about a given tracker before the
tower is able to process the appointment to tracker transition. This doesn't happen consistently,
but we've seen the error popping up every now and then.

Simply add a log based waiting to make sure the state transition is performed before asserting.
2023-01-10 13:50:58 +01:00
Sergi Delgado Segura
8c08b5fcd7
Merge pull request #168 from sr-gi/cln-plugin-auto-retry
Adds auto-retry logic to watchtower-plugin
2023-01-10 13:49:26 +01:00
Sergi Delgado Segura
39e817c51b
Fixes clippy related issues 2023-01-10 13:20:27 +01:00
Sergi Delgado Segura
09b65ef513
Fixes bug regarding re-registering
The re-register logic had a bug where the `TowerSummary` that was kept in memory
after re-registering was sweeping the references to both the pending and invalid appointments.

While this data was still in the database, re-registering may have made it look like the data was not there.
2023-01-10 13:20:27 +01:00
Sergi Delgado Segura
cc7acf6201
Adds watchtower-plugin autoretry tests 2023-01-10 13:20:26 +01:00
Sergi Delgado Segura
07caee2dbd
Revamps the cln-pugin to support retrying towers automatically
- Updates `Retrier::run` to return more meaningful errors. `Retrier::run` used to simply return a message,
revamps it to return RetryError variants so we can handle return cases better.

- Adds an additional state to `RetrierStatus`: Idle. Retries that fail due to an accumulation of transient errors will be flagged
as Idle instead of Failed and retried later on (based on `auto_retry_delay`). Notice Retrier data is not kept in memory while a retrier is Idle. Instead, data is cleared and loaded again from the database when the `Retrier` is ready to run again.

- Revamps how revocation data is sent to the `RetryManager`: The RetrierManager used to received locators one by one via unreachable_towers. This is due to them being mainly fed by `on_commitment_revocation`, which generates them one by one. However, both when manually retrying or when bootstrapping from an already populated database, multiple appointments may be pending for the same tower, hence needing to call `unreachable_towers.send` multiple times for the same tower. This itself was not a big deal, given we didn't really needed to differentiate between the cases. We do now though.
In order to implement periodic retries while allowing manual retries we need to be able to signal the state transition to the
`Retrier` without providing any new data:

- If a Retrier is idle and we receive data trough `on_commitment_revocation` we need to append that data to the `Retrier`.
- If a Retrier is iddle and we receive data trough a manual retry, we need to change the state of the `Retrier` without
  adding any new data to it.

In order to implement this we've added an additional map to `WTClient` that reports the state of the active retriers. Retriers are active only if they are running or idle.

- Also reworks `WTClient::set_tower_status` to update the status only if the new one does not match the old one.
This is simply to reduce the boiler plate of having to perform this check in other pats of the plugin codebase.
2023-01-10 13:20:26 +01:00
Sergi Delgado Segura
909a0dac5b
Merge pull request #171 from weareseba/bugfix/btcnetwork
using mainnet rather than bitcoin in the output of the help command
2023-01-10 13:19:55 +01:00
Richard Ulrich
4a7a2e0228
using mainnet rather than bitcoin in the output of the help command 2023-01-09 12:35:23 +01:00
Sergi Delgado Segura
1faf361a7f
Merge pull request #134 from sr-gi/cln-json-params
Adds named arguments to CoreLN plugin
2022-12-14 13:15:23 -06:00
Sergi Delgado Segura
d0a476d4af
Adds named arguments to CoreLN plugin 2022-12-14 11:59:19 -06:00
Sergi Delgado Segura
ae91cd5ecb
Merge pull request #164 from sr-gi/69-auto-renew-v2
Implements auto-register for the watchtower-client
2022-12-13 09:34:37 -06:00
Sergi Delgado Segura
e816d9e152
Adds a helper fn to get tower_status from WTClient
This helps reducing some of the WTClient is queried only to get the tower status
2022-12-12 10:30:59 -06:00
Sergi Delgado Segura
a6ef6946ab
Implements auto-register for the watchtower-plugin 2022-12-12 10:30:56 -06:00
Sergi Delgado Segura
d811760db7
Merge pull request #135 from sr-gi/cln-strs
Minor improvements regarding Strings in cln-plugin
2022-12-11 11:47:27 -06:00
Sergi Delgado Segura
b5f9a1e037
into() -> to_owned() for Strings
`to_owned` is more explicit.
2022-12-11 11:15:50 -06:00
Sergi Delgado Segura
f7153c4933
Replaces net_addr params from String to &str 2022-12-11 11:15:50 -06:00
Sergi Delgado Segura
cfca86fdba
Merge pull request #107 from JonathanPlasse/refactor-select-star
Refactor to avoid using `SELECT *`
2022-12-11 10:42:48 -06:00
Jonathan Plasse
f7d144c6f3
Refactor to avoid using SELECT * 2022-12-11 16:19:16 +01:00
Sergi Delgado Segura
af6cc8d584
Merge pull request #110 from JonathanPlasse/replace-base_url-by-address
Replace server.address() by server.base_url()
2022-12-10 11:22:31 -06:00
Jonathan Plasse
1e295e1661
Replace server.address() by server.base_url()
Signed-off-by: Jonathan Plasse <13716151+JonathanPlasse@users.noreply.github.com>
2022-12-10 16:27:27 +01:00
Sergi Delgado Segura
dc02abd6ed
Merge pull request #167 from sr-gi/e2e-test-no-sleep
Replaces sleep based waiting for log entry lookups
2022-12-10 09:34:27 -05:00
Sergi Delgado Segura
14176bd31d
Fixes #156
`TeosD` outputDir was being set to the same directory use by the test suite.
That cause it to be logging in the same log file, potentially colluding for
read/write operations. Setting the outputDir to it's own location fixed the issue.
2022-12-09 17:18:36 -06:00
Sergi Delgado Segura
5177446629
Replaces sleep based waiting for log entry lookups
Replaces time.sleep in the `watchtower-plugin` tests to wait for certain things to happen
on the backend by wait_for_logs, which should be less error prone

Also does some reformating and removes unnecessary imports
2022-12-09 16:23:02 -06:00
Sergi Delgado Segura
6f8ac04482
Merge pull request #166 from sr-gi/failed-register-status
Avoids setting the state of unregistered towers
2022-12-09 17:22:18 -05:00
Sergi Delgado Segura
da3daddba8
Avoids setting the state of unregistered towers 2022-12-08 05:57:31 -05:00
Sergi Delgado Segura
831454d505
Merge pull request #161 from sr-gi/fix-159
Removes redundant sanity check for Retrier::start
2022-12-01 12:20:34 +01:00
Sergi Delgado Segura
246511d0bc
Removes redundant sanity check for Retrier::start 2022-11-30 10:31:33 +01:00
Sergi Delgado Segura
c14c7364fd
Merge pull request #151 from sr-gi/gettowerinfo_addresses
Adds addresses field to `gettowerinfo`
2022-11-25 15:47:38 +01:00
Sergi Delgado Segura
0c1b4c17b4
Adds addresses to gettowerinfo 2022-11-25 14:25:53 +01:00
Sergi Delgado Segura
441a37155d
Revamps tor.rs so the onion address can be queried 2022-11-25 14:25:48 +01:00
Sergi Delgado Segura
44daa84cbc
Merge pull request #157 from sr-gi/tmp-patch-156
Adds a fixed sleep so `TeosD` has time to bootstrap in the `watchtower-plugin` tests
2022-11-25 13:52:17 +01:00
Sergi Delgado Segura
f63738e038
Adds a fixed sleep so TeosD has time to bootstrap in the watchtower-plugin tests
This is a hotfix and should be properly fix
2022-11-24 17:19:09 +01:00
Sergi Delgado Segura
5241741e76
Merge pull request #153 from sr-gi/cln-plugin-0.1.1
Minimal changes to make the plugin compatible with cln-plugin 0.1.1
2022-11-23 18:24:33 +01:00
Sergi Delgado Segura
6c6d423054
Minimal changes to make the plugin compatible with cln-plugin 0.1.1 2022-11-22 18:26:01 +01:00
Sergi Delgado Segura
409eea8da6
Merge pull request #150 from mariocynicys/docs-lockin-installs
use --locked with cargo install
2022-11-12 15:48:52 -03:00
Omer Yacine
d212aae0da
use --locked with cargo install
Using --locked with cargo install(s) will instruct cargo to use the lock file in the repo (Cargo.lock).
So all the users' builds will be identical to builds in the master branch.

Signed-off-by: Omer Yacine <mariocynicys@gmail.com>
2022-11-12 20:32:10 +02:00
Sergi Delgado Segura
832dd71ae5
Merge pull request #145 from sr-gi/tor-default-port
Changes the onion hidden service port to match the clearnet API port
2022-11-12 12:09:55 -03:00
Sergi Delgado Segura
c4b5fd1eb1
Changes the onion hidden service port to match the clearnet API port
Turns out both the clearnet API and the Tor hidden service can be run on the same
port.
2022-11-12 11:52:27 -03:00
Sergi Delgado Segura
eef55adb0c
Merge pull request #146 from sr-gi/fix-end-proxy-redirection
Fixes tor proxy redirection
2022-11-12 11:51:36 -03:00
Sergi Delgado Segura
ecaba32e3e
Fixes tor proxy redirection
Traffic routed trough Tor was being redirected to localhost indistinguishably of whether
the public http API was being offered there or not. This made the tower unavailable
(trough Tor) when it was being offered both in clearnet and Tor.
2022-11-12 11:33:16 -03:00
Sergi Delgado Segura
efda0ad7ca
Merge pull request #149 from sr-gi/track-cargo-lock
Tracks Cargo.lock
2022-11-12 11:31:06 -03:00
Sergi Delgado Segura
12f8b48f57
Tracks Cargo.lock
Rationale: https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
2022-11-12 10:37:00 -03:00
Sergi Delgado Segura
6f107361ab
Merge pull request #144 from sr-gi/rpc_commands_lower_case
Changes rpc commands naming from snake case to lower case
2022-11-10 09:58:41 -03:00
Sergi Delgado Segura
a20065567f
Changes rpc commands naming from snake case to lower case 2022-11-08 14:13:27 +01:00
Sergi Delgado Segura
eb40ac0141
Merge pull request #132 from sr-gi/130-txindex
Implements an internal txindex so we don't have to run bitcoind with `txindex=1`
2022-11-07 12:30:19 +01:00
Sergi Delgado Segura
221358d6d4
Updates docs regarding txindex 2022-11-07 11:57:50 +01:00
Sergi Delgado Segura
1788b4d722
Adds TxIndex to Responder 2022-11-07 11:57:44 +01:00
Sergi Delgado Segura
770b6e6db7
Generalizes LocatorCache into a TxIndex
In order to fix #130 we need to implement our own txindex. Turns out this is almost identical
to our `LocatorCache`, so we can generalize it and use it for both purposes.
2022-11-07 11:57:38 +01:00
Sergi Delgado Segura
e7ad7a4ef7
Merge pull request #141 from sr-gi/update-actions
Updates actions
2022-11-04 19:23:56 +01:00
Sergi Delgado Segura
8e44159dd6
Fixes an uncaught clippy issue prior to rust 1.65.0 2022-11-04 19:09:38 +01:00
Sergi Delgado Segura
2765a4ce93
Updates actions 2022-11-04 19:09:38 +01:00
Sergi Delgado Segura
8fda69793e
Merge pull request #139 from sr-gi/fix-third-party-logging
Fix third party logging
2022-11-03 17:14:00 +01:00
Sergi Delgado Segura
22dc24c561
Removes debug from teos-cli
teos-cli is not using logging, so the debug flag was useless.
2022-11-03 12:10:38 +01:00
Sergi Delgado Segura
0ca6f80149
Splits log levels into local and third party libs
The log level was setup equally between local code and third party libs, making
really verbose libs log a lot of unnecessary data when setting loglevel to debug
for instance.

This splits the loglevel config option into two: `debug` and `depsdebug`. The latter
covers third party libs.
2022-11-03 12:10:38 +01:00
Sergi Delgado Segura
e012a890d3
Merge pull request #140 from sr-gi/coreln-post-loglevel
Change log level for failed post requests in CoreLN plugin
2022-11-03 12:10:04 +01:00
Sergi Delgado Segura
fc9145b1b7
Change log level for failed post requests in CoreLN plugin
Let's be less dramatic, specially given connection timeouts fall under this category.
2022-11-01 15:17:04 +01:00
Sergi Delgado Segura
e19bb209f6
Merge pull request #127 from sr-gi/remove-unused-deps
Removes chunked-transfer from deps
2022-09-21 11:51:34 +00:00
Sergi Delgado Segura
b1b1d83659
Removes chunked-transfer from deps
chunked-transfer was used in an old version of the test suite for a custom HTTP server.
The test server was removed in bbd3857c70, hence the dependency was
never used from that point on.
2022-09-21 10:45:04 +00:00
Sergi Delgado Segura
77d8ac0afc
Merge pull request #126 from sr-gi/fix-tor-readme
Fixes some wording in the Tor section of the README
2022-09-21 09:52:11 +00:00
Sergi Delgado Segura
81334d739e
Fixes some wording in the Tor section of the README 2022-09-21 09:31:53 +00:00
Sergi Delgado Segura
ab22bed3e6
Merge pull request #122 from sr-gi/bump-teos-version
Bumps teos version to 0.1.2
2022-09-20 13:16:42 +00:00
Sergi Delgado Segura
b6e223a4ed
Bumps version to 0.1.2
Makes it so the .rs files pull the version info from the Cargo files so it is always consistent
2022-09-20 12:41:59 +00:00
Sergi Delgado Segura
a3d4ce7793
Merge pull request #124 from sr-gi/arc-retriers
fix: Arc retriers so we reduce the cloning + antipatern
2022-09-20 12:41:23 +00:00
Sergi Delgado Segura
35a15b14eb
fix: Arc retriers so we reduce the cloning + antipatern
One thing I really disliked about #89 was having a method that cloned its caller to be able to work around spawning a task inside it that called a method of the same class.

Turns out you can have self as Arc<Self> which would completely prevent having to do such a thing, plus it also reduces the number of things being cloned.
2022-09-20 12:25:00 +00:00
Sergi Delgado Segura
0eeef340ce
Merge pull request #123 from meryacine/match-bitcoin-and-teos-network
Sanity check that bitcoind is running on the specified network
2022-09-20 12:23:50 +00:00
meryacine
6461e7e72b
teosd: Normalize bitcoin chain network naming 2022-09-20 12:42:46 +02:00
meryacine
b0f294a17b
teosd: Sanity check that bitcoind is running on the specified network 2022-09-20 08:29:26 +02:00
meryacine
16d760d9ed
docs: Update an outdated link 2022-09-20 08:29:24 +02:00
Sergi Delgado Segura
8867de175f
Merge pull request #117 from meryacine/free-dbm-of-arc-mutx
watchtower-plugin: get rid of unnecessary `Arc<Mutex<` in `WTClient`
2022-09-17 15:12:44 +02:00
meryacine
f0f9879338
watchtower-plugin: get rid of unnecessary arc<mutex< in WTClient 2022-09-17 14:55:35 +02:00
Sergi Delgado Segura
35fd5b0ab6
Merge pull request #121 from sr-gi/too-many-fs
Fixes #119
2022-09-17 14:53:05 +02:00
Sergi Delgado Segura
fec3790494 Fixes #119
Wraps `CloseHandle` inside `BitcoindStopper` and implements `Drop` for the latter so the server does not need to be manually stopped
2022-09-17 14:33:28 +02:00
Sergi Delgado Segura
51b384f0d9
Merge pull request #120 from meryacine/fix-blockchain_atheight_panic
tests: Fix `at_height` may panic
2022-09-17 14:31:46 +02:00
meryacine
388c643109
tests: Fix with_height may produce invalid blocks
`with_height` test method might produce invalid blocks because, after #79, we can't create empty blocks any more, and some random txs might result in a not enough proof of work as in #56
2022-09-17 14:18:32 +02:00
Sergi Delgado Segura
3c19619b09
Merge pull request #89 from sr-gi/84-unreachable-towers
Fixes race condition in #84
2022-09-17 14:12:27 +02:00
meryacine
f6a60a9655 Rework the retrier
This is an attempt to rework the retrier logic to simplify how it works
and make it less error prone. This is done by making the retry manager
object responsible for both:
1- adding new retriers and extending current ones
2- removing retriers when they finish their work

This way, we don't need a mutex to gaurd the retriers hashmap & we are
sure there is no adding/extending retriers and removing them happending
at the same time, because only the retry manager does it and not
single retriers (i.e. retriers can't remove themselves from the retriers
hashmap).

The retry manager logic goes as follows:
1- drain the unreachable towers channel till it's empty, and store the pending appointments (locators to be exact) in the pending appointments set for each retrier.
2- remove any finished retrier (ones that succeeded and have no more pending appointments) and failed retriers (ones that failed to send their appointments).
3- start all the non-running retriers left after removing failed and finished retrieres.

Retriers will signal thier status so that the retry manager could
determine which retriers to keep, which to remove, and which to re-start.

We also set tower as unreachable when destroying the tower's retrier
and not after completing backoff. This makes it so that the tower is
unreachable until its retrier is destroyed, thus manual tower retry
by the user will fail with an error till the tower's retrier is destroyed.

If we were to set the unreachable tower status after the backoff, then manual
user retries might get discarded completely without an error because retrier
set the tower state to unreachable too early thus allowing the user to
perform manual retries, but if the user does manual retry, it won't get
carried out, since the retry manager will remove that retrier anyway as
it failed to deliver its pending appointments.
2022-09-16 23:44:43 +02:00
Sergi Delgado Segura
c5f9c3a8c4 Fix #84 and revamps the retrier 2022-09-16 23:44:21 +02:00
Sergi Delgado Segura
605f6fb45e
Merge pull request #114 from sr-gi/fix-watchtower-test
Fixes E2E test test_watchtower
2022-09-04 10:10:13 +03:00
Sergi Delgado Segura
21c82dcc87 Fixes E2E test test_watchtower
`watchtower-plugin::tests::test::test_watchtower` was not properly checking that
an appointment could not be found after resolving. This was due to an off-by-one error
that made the resolution not actually happening.
2022-09-02 08:48:13 +03:00
Sergi Delgado Segura
10389f1431
Merge pull request #106 from sr-gi/http-ready-signal
Adds a ready signal for the http API
2022-08-30 14:13:37 +02:00
Sergi Delgado Segura
b2dc2e908b Simplifies bind_with_graceful_shutdown for http api
`bind_with_graceful_shutdown` requires the shutdown signal to implement `std::future::Future`.
Turns out `triggered::Listener` already does, so there is no need to wrap this in an async block.
2022-08-30 13:45:39 +02:00
Sergi Delgado Segura
013df67402 Adds a ready signal for the http API
In normal conditions, if the http API cannot bootstrap (due to the gRPC
server being temporary unavailable) it will just retry until it connects.

However, this is done asynchronously, meaning that it could be the case that
the tower is reported as ready when it is actually not. This can be hit during
E2E testing if the tower is restarted too quickly, resulting in a test failure:

https://github.com/sr-gi/rust-teos/runs/8074272734?check_suite_focus=true#step:9:5543
https://github.com/sr-gi/rust-teos/runs/8074272734?check_suite_focus=true#step:9:6970
2022-08-30 13:45:39 +02:00
Sergi Delgado Segura
29466bee2e
Merge pull request #104 from sr-gi/guard-empty-batch-deletion
Avoid having to hit the db if there is no data to delete
2022-08-30 13:44:59 +02:00
Sergi Delgado Segura
a1aea5dcec Avoid having to hit the db if there is no data to delete 2022-08-30 13:30:48 +02:00
Sergi Delgado Segura
fb5651ef1e
Merge pull request #103 from sr-gi/cln-tor-support
Updates the plugin README with Tor info
2022-08-30 13:30:12 +02:00
Sergi Delgado Segura
4ce991fae0 Updates the plugin README with Tor info 2022-08-29 16:06:27 +02:00
Sergi Delgado Segura
f2e99fab02
Merge pull request #96 from sr-gi/cln-tor-support
Adds Tor support to cln-plugin
2022-08-29 15:07:59 +02:00
Sergi Delgado Segura
c2bc0d6106 Remove unexpected_error tests 2022-08-29 14:41:00 +02:00
Sergi Delgado Segura
0708b15157 Adds proxy option 2022-08-29 14:41:00 +02:00
Sergi Delgado Segura
eac238628e Adds Tor support to cln-plugin 2022-08-29 14:41:00 +02:00
Sergi Delgado Segura
56bfe4c9e5
Merge pull request #99 from sr-gi/fix-tor-panic
Makes Tor task panics more user friendly
2022-08-26 15:33:24 +02:00
Sergi Delgado Segura
47ba8b0cb9 Makes Tor task panics more user friendly 2022-08-26 15:10:20 +02:00
Sergi Delgado Segura
d0a3acc559
Merge pull request #98 from sr-gi/fix-81
Updates register for re-register
2022-08-26 15:09:19 +02:00
Sergi Delgado Segura
e70561d39e Updates register for re-register
Makes it so if a user registers more than once the expiry is not based on the current
height but on the old expiry.
2022-08-24 15:50:36 +02:00
Sergi Delgado Segura
461206f334
Merge pull request #95 from sr-gi/persist-tor-sk
Persists Tor secret key to disk
2022-08-22 12:26:22 +02:00
Sergi Delgado Segura
3b7842a0f4 Properly capitalizes Tor
See https://support.torproject.org/about/why-is-it-called-tor/
2022-08-22 11:37:32 +02:00
Sergi Delgado Segura
2373681402 Persists Tor secret key to disk 2022-08-22 11:29:53 +02:00
Sergi Delgado Segura
2e9a3c5de5
Merge pull request #83 from sr-gi/cln-plugin-additional-commands
Adds some extra commands to the cln-plugin
2022-08-22 11:28:55 +02:00
Sergi Delgado Segura
a42a5eebad Fixes missing tmp_dir for watchtower-plugin
Some of the tmp paths in the `watchtower-plugin` tests were not using
`TempDir` and still manually removing the directories.
2022-08-22 11:11:39 +02:00
Sergi Delgado Segura
33148c16ba Adds some extra commands to the cln-plugin
- `abandontower <tower_id>` will remove all data associated to a given tower
- `getappointmentreceipt <tower_id> <locator>` will pull a given appointment receipt
from the local database.
- `getregistrationreceipt <tower_id>` will pull a given registration receipt from
the local database.
2022-08-17 12:26:50 +02:00
Sergi Delgado Segura
33cd6948bf
Merge pull request #86 from sr-gi/cln-plugin-getsubscriptioninfo
Adds getsubscriptioninfo to the CLN plugin
2022-08-17 10:17:06 +02:00
Sergi Delgado Segura
d4a293feb2 Adds getsubscriptioninfo to the CLN plugin 2022-08-16 12:41:10 +02:00
Sergi Delgado Segura
db65e13ccc
Merge pull request #82 from sr-gi/64-fix-serialization
Fixes Tracker serialization for get_all_appointments
2022-08-16 12:26:11 +02:00
Sergi Delgado Segura
7e7dc973f5 Fixes Tracker serialization for get_all_appointments
close #64
2022-08-16 12:08:27 +02:00
Sergi Delgado Segura
4b70825adc
Merge pull request #94 from sr-gi/fix-clippy-eq-protos
Disable clippy::derive_partial_eq_without_eq in protos
2022-08-16 11:55:33 +02:00
Sergi Delgado Segura
3d816cc8af Disable clippy::derive_partial_eq_without_eq in protos
Clippy for Rust 1.63.0 raises a lint warning regarding structures implementing
PartialEq but not Eq: https://rust-lang.github.io/rust-clippy/master/index.html#derive_partial_eq_without_eq

The autogenerated code from the protos does fall into this constrain. However, the current recommended
solution from the `prost` team is to disable the check: https://github.com/tokio-rs/prost/issues/661
2022-08-16 11:12:19 +02:00
Sergi Delgado Segura
d7fe462e35
Merge pull request #80 from booklearner/62
change `rename_all` macro value to `snake_case` for user_id
2022-08-01 12:20:31 +02:00
booklearner
2c91e94e70
change rename_all macro value to snake_case 2022-07-31 12:19:51 -04:00
Sergi Delgado Segura
e0905788eb
Merge pull request #79 from meryacine/pump-rust-lightning
Pump rust-lightning to v0.0.108
2022-07-31 18:02:23 +02:00
meryacine
50356fee97
Renaming block_connected to filtered_block_connected
Implement `filtered_block_connected` and rely on `block_connected` default implementation that calls `filtered_block_connected`
2022-07-31 16:28:16 +02:00
meryacine
b5fe75a5be
Bump up some deps
Needed to bump the lightning version used and had to bump bitcoin, bitcoincore-rpc aswell to match dep verions in lightining
merkle root computation changed in `bitcoin`, thus some methods in the test_utils needed to adapt (basically by adding a tx if there is none in a block). See: b454cf8e15
Also SecretKey had it's `to_string` method removed, so TEOS now encodes its tower key using `display_secret().to_string()`. See: https://github.com/rust-bitcoin/rust-secp256k1/pull/312
2022-07-31 16:28:15 +02:00
Sergi Delgado Segura
66a1eefb08
Merge pull request #77 from sr-gi/fix-registration-receipts
Add subscription_start to registration receipt
2022-07-22 11:42:19 +02:00
Sergi Delgado Segura
c14543d6ea Add start_time to registration receipt 2022-07-19 15:46:04 +02:00
Sergi Delgado Segura
3175acafcc
Merge pull request #75 from sr-gi/pytest-timeout
Adds a timeout to Python tests that may wait forever
2022-07-12 10:14:53 +02:00
Sergi Delgado Segura
4a73548065 Adds a timeout to Python tests that may wait forever
Some E2E test for the CLN plugin have conditional waits that may wait forever
if the code has a bug. Set a timeout for an early fail so we don't have to waste
CI time.
2022-07-12 09:52:26 +02:00
Sergi Delgado Segura
35f3c03fef
Merge pull request #73 from sr-gi/fix-readmes
Fixes some typos in the readmes
2022-07-11 17:34:45 +02:00
Sergi Delgado Segura
a70f9c47c8 Fixes some typos in the readmes
Superseeds #27 and #47
2022-07-11 17:10:37 +02:00
Sergi Delgado Segura
91aaa00a99
Merge pull request #74 from sr-gi/minor-fixes
Minor fixes re: tests and logs
2022-07-11 17:07:32 +02:00
Sergi Delgado Segura
b0e169a549 Minor fixes re: tests and logs
- Adds a log to signal the tower is ready
- Updates the message to wait for in tests
- Removes unnecessary logs in tls
- Sets tests to run in SLOW MACHINE mode, given tests tend to fail in CI
apparently due to that
2022-07-11 16:28:32 +02:00
Sergi Delgado Segura
f30ee8a294
Merge pull request #67 from sr-gi/56-fix-blockchain-generate
Makes sure generated blocks have valid pow
2022-07-11 10:33:30 +02:00
Sergi Delgado Segura
82e452c6b7 Makes sure generated blocks have valid pow
Close #56
2022-07-08 17:18:56 +02:00
Sergi Delgado Segura
5f957ef5ab
Merge pull request #53 from talaia-labs/cln-plugin
Adds watchtower plugin for CoreLN
2022-07-08 17:17:36 +02:00
Sergi Delgado Segura
037743c395 Adds installation and linking instructions to cln plugin 2022-07-08 17:16:11 +02:00
Sergi Delgado Segura
c0ba31eb0e
Merge pull request #68 from sr-gi/cln-plugin-retrier-object
Transforms the retrier into an object
2022-07-08 16:45:35 +02:00
Sergi Delgado Segura
109cd20abb Transforms the retrier into an object
Also minimizes the amount of `lock().unwrap()`s for when accessing the `wt_client`
to reduce the overhead.
2022-07-08 12:40:34 +02:00
Sergi Delgado Segura
ed485a0aed Slightly extends waiting time for test_manage_retry_unreachable
I haven't been able to reproduce this on my local environment, but some times
test_manage_retry_unreachable hits an assertion error because the tower state
is still temporary unreachable instead of unreachable when checking. This can
be fixed by simply waiting slightly more.

https://github.com/talaia-labs/rust-teos/runs/7232759066?check_suite_focus=true#step:5:778
2022-07-07 15:37:26 +02:00
Sergi Delgado Segura
6fd6ebb0b9 Renames watchtower-plugin binary to watchtower-client
CLN logs plugin entries as:

plugin-<plugin-name> so by calling the binary `watchtower-plugin` the log
lines read plugin-watchtower-plugin, which is quite redundant. plugin-watchtower-client
seems more adequate.
2022-07-07 15:10:25 +02:00
booklearner
fbb6f84e68 Fixes GH actions cache hit condition
Turns out `cache-hit` returns either true or nothing 🤦

kudos to @booklearner and @meryacine for this
https://github.com/talaia-labs/rust-teos/pull/72#issuecomment-1177565172
2022-07-07 15:08:12 +02:00
Sergi Delgado Segura
1f5b13e23d fix: Fixes connection between gRPC client and server
In some rare occasions, when restarting the tower, the gRPC client is unable
to connect to the server. This is a temporary error that get's fixed by simply
waiting a bit. Looks like the root of the issue if the server not being fully up
when the client tries connect.

More info: https://github.com/talaia-labs/rust-teos/pull/65#issuecomment-1164469285

The fix mainly consists on  looping with a wait until the client is able to connect.
2022-07-07 15:08:12 +02:00
Sergi Delgado Segura
f9f6b3be32 fix: Fixes incorrect log in get_appointment
`get_appointment` was logging as if the data was not found when the data
was pulled from the `Responder`, looks like it had to do with `ok_or` being
eagerly evaluated. It has been replaced by `ok_or_else`.
2022-07-07 15:08:12 +02:00
Sergi Delgado Segura
bf1abfdc1a Adds watchtower-plugin E2E tests to GH Actions 2022-07-07 15:08:12 +02:00
Sergi Delgado Segura
ba4f8641c4 Adds watchtower-plugin E2E tests 2022-07-07 15:08:12 +02:00
Sergi Delgado Segura
47b9af8206 Adds dev-watchtower-max-retry-interval option to CLN plugin
`dev-watchtower-max-retry-interval` allows for fine tunning the waiting time
between intervals of the retrier. This makes it easier to test when the retrier
must have been attempted to send data to the tower again.

For now this is set as as a dev option. Not that matter much apart from it's naming.
(i.e. it can be set in any context). If it make sense for this fine tunning to be
offered to the user, the `dev-` prefix could be dropped and it could be documented.
2022-07-07 15:08:12 +02:00
Sergi Delgado Segura
0e614273e6 Adds config::Config::is_default
Changes the way of reporting whether the tower was run with defaults or
using the config file. Now it is logged instead of printing, which feels
more inline.

This also affected the CLI, where the line reporting was shown for every
run command. That's not the case anymore.
2022-07-07 15:08:12 +02:00
Sergi Delgado Segura
141f0e20ca Adds Responder log for Tracker confirmations 2022-07-07 15:08:12 +02:00
Sergi Delgado Segura
6405dcd8d0 Adds retry strategy to cln-plugin 2022-07-06 18:56:52 +02:00
Sergi Delgado Segura
4b5c7811c7 Updates GH Actions 2022-07-06 17:39:47 +02:00
Sergi Delgado Segura
83577c08bb Move reqwest client building to http 2022-07-06 17:39:47 +02:00
Sergi Delgado Segura
315ecd1072 Adds CLN plugin README and updates global README 2022-07-06 17:39:47 +02:00
Sergi Delgado Segura
f1a2d58ee9 Pulls cln-plugin from crates.io 2022-07-06 17:39:47 +02:00
Sergi Delgado Segura
409f04e622 Adds the core logic of the CoreLN tower plugin 2022-07-06 17:39:47 +02:00
Sergi Delgado Segura
1e020eac44 Allows receipts to be built including the signature and adds verify method
This new constructor useful for passing the information along in the clients,
avoiding moving around a struct with an empty field and the signature separately.
On the other hand, the verify functions makes it easier cleaner a valid receipt.
2022-07-06 17:39:47 +02:00
Sergi Delgado Segura
0b01ae1a52 Adds "use-serde" feature to bitcoin crate
In order to derive serde from UserId (which in turn derives it from PublicKey)
use-serde needs to be enabled.
2022-07-06 17:39:47 +02:00
Sergi Delgado Segura
382cce1ec5 Moves compute_appointment_slots to teos-common 2022-07-06 17:39:47 +02:00
Sergi Delgado Segura
063bf44c9d Aliases UserId to TowerId for readability 2022-07-06 17:39:47 +02:00
Sergi Delgado Segura
2cbdfff340 Splits the DBM so some of its parts can be commonly used
teos-common now have some DBM traits that need to be implemented by DBM.
Some common functionality is also implemented to reduce code replication.
2022-07-06 17:39:47 +02:00
Sergi Delgado Segura
6b635c2546 Creates teos_common/test_utils and moves utils that can be used by clients from teos/test_utils 2022-07-06 17:39:44 +02:00
Sergi Delgado Segura
d871bc133c Reworks serde methods for local types
Local types were using a mix between serialize / deserialize / to_vec / from_slice
methods to serialize / deserialize structures without using the serde crate.

Normalizes the method naming to it/s to_vec / from_slice. May reformat to serialize / deserialize
if we end up using serde for that.

Also adds some utility serde methods and derives serde from some structs that required so for client building.
2022-07-06 17:30:50 +02:00
Sergi Delgado Segura
c777dd69d4 Splits protos in main and common
Some functionality can be reused by clients, like the API requests/responses,
so splitting the protos will reduce the boilerplate when building them.
2022-07-06 17:23:04 +02:00
Sergi Delgado Segura
65f0709f78
Merge pull request #59 from orbitalturtle/mtls-auth-rpc
Mtls auth rpc
2022-07-04 09:08:48 +02:00
Orbital
2128de81e2
Docs: explain how to run cli remotely
Signed-off-by: Orbital <orbitalturtle@protonmail.com>
2022-06-15 19:58:20 -05:00
Orbital
3aee52cad1
Make cli connect to tower server via tls
Signed-off-by: Orbital <orbitalturtle@protonmail.com>
2022-06-15 19:58:20 -05:00
Orbital
b2419f597c
Require mtls for server private api
Signed-off-by: Orbital <orbitalturtle@protonmail.com>
2022-06-15 19:58:19 -05:00
Sergi Delgado Segura
0ba8aa141d
Merge pull request #46 from meryacine/get-appointments-cli
feat: `get_appointments` cli command
2022-06-10 14:32:57 +02:00
meryacine
d5578110f1
add a get_appointments (using a locator) command
adding tests to the get_appointments endpoint

refactoring tests
This commit also does a minor refactoring: removes un-needed ::<HashSet<*>> annotations in the dbm tests

Add add_dummy_tracker_to_responder method to reuse code

This removes add_tracker_to_responder

Fix a clippy warning
2022-06-10 13:09:00 +02:00
Sergi Delgado Segura
8531f1c864
Merge pull request #60 from talaia-labs/fix-clippy-rust-1.61
Fixes a clippy issue in main raised by rustc 1.61.0
2022-06-06 11:19:36 +02:00
Sergi Delgado Segura
b67237edf5 Fixes a clippy issue in main raised by rustc 1.61.0
Fixes a clippy issue in main where an async method was called while holding
the DB MutexGuard.

https://rust-lang.github.io/rust-clippy/master/index.html#await_holding_lock
2022-06-01 19:55:25 +02:00
Sergi Delgado Segura
9e95b6aeff
Merge pull request #48 from tee8z/tor-endpoint-http-api
Tor endpoint http api
2022-05-18 22:23:24 +02:00
Tee8z
d2999c221a
Adds note to README on how to run tor and use it with rust-teos 2022-05-18 12:12:41 -04:00
teebz
02c508d469
Creates a hidden tor service based on a configuation value that proxies request to a local instance of the public API 2022-05-18 12:12:30 -04:00
Sergi Delgado Segura
e1dbff01ec
Merge pull request #52 from meryacine/rustfmt-for-ci
Add rustfmt and clippy to the CI
2022-05-18 09:54:08 +02:00
meryacine
17957c7ebb
add allow upper-case-acronyms annotation for UUID
See https://rust-lang.github.io/rust-clippy/master#upper_case_acronyms
2022-05-13 12:05:36 +02:00
meryacine
90a307d9fe
Add clippy for linting
Fail the workflow if there are warnings
2022-05-13 12:04:07 +02:00
meryacine
8b1f6db5c5
Add formatting checks to the CI 2022-04-29 11:20:51 +02:00
Sergi Delgado Segura
9439524e8e
Merge pull request #51 from talaia-labs/bitcoin_serde_consistency
Uses serde functions from bitcoin crate in a consistent manner
2022-04-26 11:30:25 +02:00
Sergi Delgado Segura
a6d0b6f311 Uses serde functions from bitcoin crate in a consistent manner
The codebase had different ways of performing bitcoin serde:

- Using bitcoin::consensus::{encode, decode}
- Using bitcoin::util::psbt::serialize::{Deserialize, Serialize};

The former feels like a cleaner way of doing so, normalizing all the appearances.
2022-04-26 10:50:59 +02:00
Sergi Delgado Segura
86b041a291
Merge pull request #44 from talaia-labs/fix-startup-defaults
Fix startup defaults
2022-04-20 09:48:24 +02:00
Sergi Delgado Segura
9cc2847564 Returns a more friendly error when btcrpc user and/or password are/is incorrect 2022-04-19 15:52:19 +02:00
Sergi Delgado Segura
bdf7668ead Fixes default rpc ports 2022-04-19 15:52:19 +02:00
Sergi Delgado Segura
364c5d5445
Merge pull request #45 from meryacine/improving-workflow-readablity
Fixing failing workflow
2022-04-19 15:44:28 +02:00
meryacine
3ac228c662
compiling lightning v0.0.99 up till v0.0.104 fails with the current rust stable toolchain
version 0.0.105 fixes the compilation issue with cargo 1.60.0.
2022-04-19 12:11:31 +02:00
meryacine
84511b6e7a
nit: improving the readablity of the workflow 2022-04-07 09:11:07 +02:00
76 changed files with 18104 additions and 4354 deletions

View file

@ -1,34 +1,82 @@
name: Continuous Integration Checks
on: [push, pull_request]
on:
push:
branches:
- master
pull_request:
jobs:
build:
strategy:
fail-fast: false
matrix:
platform: [ ubuntu-latest ]
platform: [ ubuntu-latest, macos-latest, windows-latest ]
toolchain: [ stable ]
include:
- toolchain: stable
- toolchain: stable
platform: macos-latest
- toolchain: stable
platform: windows-latest
- platform: windows-latest
arguments: --workspace --exclude watchtower-plugin
runs-on: ${{ matrix.platform }}
steps:
- name: Checkout source code
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Install Rust ${{ matrix.toolchain }} toolchain
uses: actions-rs/toolchain@v1
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ matrix.toolchain }}
override: true
profile: minimal
- name: Install Protoc
uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Build on Rust ${{ matrix.toolchain }}
run: |
cargo build --verbose --color always
cargo build ${{ matrix.arguments }} --verbose --color always
- name: Test on Rust ${{ matrix.toolchain }}
run: |
cargo test --verbose --color always
cargo test ${{ matrix.arguments }} --verbose --color always
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Install Rust stable toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
components: rustfmt, clippy
- name: Install Protoc
uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Run rustfmt
run: |
cargo fmt --verbose --check -- --color always
- name: Run clippy
run: |
cargo clippy --all-features --all-targets --color always -- --deny warnings
python-lint:
runs-on: ubuntu-latest
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Run black
uses: psf/black@stable
with:
src: "./watchtower-plugin/tests"
options: "--check -l 120"
check-flake:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Check Nix flake inputs
uses: DeterminateSystems/flake-checker-action@v12
- name: Install Nix
uses: cachix/install-nix-action@v31
- name: Check flake
run: nix flake check

96
.github/workflows/cln-plugin.yaml vendored Normal file
View file

@ -0,0 +1,96 @@
name: CI tests for CLN watchtower-plugin
on:
push:
branches:
- master
pull_request:
env:
bitcoind_version: "27.0"
cln_version: "24.11.1"
jobs:
check-cln-cache:
runs-on: ubuntu-latest
outputs:
cache-hit: ${{ steps.cache.outputs.cache-hit }}
steps:
- name: Check CLN cache
id: cache
uses: actions/cache@v4
with:
path: lightning
key: ${{ runner.os }}-build-cache-cln-dev-v${{ env.cln_version }}
cache-cln:
runs-on: ubuntu-latest
needs: check-cln-cache
if: ${{ needs.check-cln-cache.outputs.cache-hit != 'true' }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.9'
check-latest: true
- uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Create CLN cache
uses: actions/cache@v4
with:
path: lightning
key: ${{ runner.os }}-build-cache-cln-dev-v${{ env.cln_version }}
- name: Compile CLN
env:
PYTHON_KEYRING_BACKEND: keyring.backends.null.Keyring
run: |
sudo apt-get update && sudo apt-get install -y gettext
git clone https://github.com/ElementsProject/lightning.git && cd lightning && git checkout v${{ env.cln_version }}
pip install --user poetry && poetry install
./configure && poetry run make -j 8
cln-plugin:
needs: cache-cln
if: ${{ always() }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.9'
check-latest: true
- uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
components: rustfmt, clippy
- name: Install bitcoind
run: |
wget https://bitcoincore.org/bin/bitcoin-core-${{ env.bitcoind_version }}/bitcoin-${{ env.bitcoind_version }}-x86_64-linux-gnu.tar.gz
tar -xzf bitcoin-${{ env.bitcoind_version }}-x86_64-linux-gnu.tar.gz
ln -s $(pwd)/bitcoin-${{ env.bitcoind_version }}/bin/bitcoin* /usr/local/bin
- name: Load CLN cache
id: cache-cln
uses: actions/cache@v4
with:
path: lightning
key: ${{ runner.os }}-build-cache-cln-dev-v${{ env.cln_version }}
- name: Link CLN
run: |
cd lightning && sudo PATH=$PATH make install
- name: Install teos and the plugin
run: |
cargo install --locked --path teos
cargo install --locked --path watchtower-plugin
- name: Add test dependencies
run: |
cd watchtower-plugin/tests
pip install --user poetry && poetry install --no-root
- name: Run tests
run: |
cd watchtower-plugin/tests
VALGRIND=0 SLOW_MACHINE=1 poetry run pytest test.py --log-cli-level=INFO -s

5
.gitignore vendored
View file

@ -1,4 +1,5 @@
target
*/target
Cargo.lock
__pycache__
.vscode
.idea
result

View file

@ -9,7 +9,7 @@ We use `rustfmt` as our base code formatter. Before submitting a PR make sure yo
cargo fmt
```
In addition, we use [clippy](https://github.com/rust-lang/rust-clippy/) to detect catch common mistakes and improve the code:
In addition, we use [clippy](https://github.com/rust-lang/rust-clippy/) to catch common mistakes and improve the code:
```bash
cargo clippy
@ -70,8 +70,37 @@ pub struct Responder {
```
## Test Coverage
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.
Tests should be provided to cover both positive and negative conditions. Tests should cover both the proper execution as well as all the covered error paths. PR with no proper test coverage will not be merged.
## Signing Commits
## Git conventions
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).
### Commits, titles, and descriptions
- Changes must be split logically in commits, such that a commit is self-contained
- In general terms, all commits need to pass the test suite. There may be some exceptions to this rule if the change you are working on touches several components of the codebase and it makes more sense to split the change by component (or group of components)
- Commit titles need to be short and explanatory. If we are, for instance, adding an RPC command to the backend, "Adds command X to the backend" will be a good short description, "Add command" or "Fix #123" where #123 is an issue referencing this feature **IS NOT**
- Descriptions can be provided to give more context about what has been fixed and how
### Pull requests
- Pull request titles need to be explanatory, in the same way, commits titles were. If a PR includes a single commit, they can share the title, otherwise, a general title of what we are trying to achieve is required. **DO NOT REFERENCE ISSUES IN PULL REQUEST TITLES**, save that for the PR description
- PR descriptions need to guide the reviewer into what has been changed. You can reference issues here. If the PR is a fix of a simple issue, "Fix #123" may suffice, however, if it involves several changes, a proper explanation of both what has been fixed and how is due. These are two good examples of PR descriptions, both long and short: [188](https://github.com/talaia-labs/rust-teos/pull/188), [194](https://github.com/talaia-labs/rust-teos/pull/194)
- **WE DO NOT PILE "fix" COMMITS IN A PULL REQUEST**, that is, if some fixes are requested by reviewers, or something was missing from our original approach, it needs to be squashed. Do **NOT** do this:
```
886b0ff Adds X functionality to component Y
801ff5d Fixes the previous commit because Z
67ac345 Addresses review comments
7dc7fcd Updates X because G was missing
b60999c Adds missing test
...
```
- Create a new branch to work on your pull request. **DO NOT** work from the master branch of your fork*
- **DO NOT** merge master into your branch, rebase master instead*
\* If you're not sure how to handle this, check external documentation on how to manage multiple remotes for the same repository.
### Signing Commits
We require that all commits to be merged 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).

4100
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,8 @@
[workspace]
resolver = "2"
members = [
"teos",
"teos-common",
]
"watchtower-plugin"
]

View file

@ -1,25 +1,24 @@
# Dependencies
`rusty-teos` has the following system-wide dependencies:
`rust-teos` has the following system-wide dependencies:
- `rust`
- `bitcoind`
### Minimum Supported Rust Version (MSRV)
FIXME: Define MSRV
Refer to [toolchain](./rust-toolchain.toml)
### Installing Rust
Refer to [rust-lang.org](https://www.rust-lang.org/tools/install).
### Installing bitcoind
`rusty-teos` runs on top of a Bitcoin Core node. Other underlying Bitcoin nodes are not supported at the moment.
`rust-teos` runs on top of a Bitcoin Core node. Other underlying Bitcoin nodes are not supported at the moment.
You can get Bitcoin Core from [bitcoincore.org](https://bitcoincore.org/en/download/).
Bitcoin needs to be running with the following options enabled:
- `txindex` to be able to look for non-wallet transactions
- `server` to run rpc commands
Here's an example of a `bitcoin.conf` you can use for mainnet. **DO NOT USE THE PROVIDED RPC USER AND PASSWORD.**
@ -31,9 +30,6 @@ rpcuser=user
rpcpassword=passwd
rpcservertimeout=600
# [blockchain]
txindex=1
# [others]
daemon=1
debug=1

View file

@ -3,9 +3,9 @@
The tower can be installed and tested using cargo:
```
git https://github.com/sr-gi/rusty-teos.git
cd rusty-teos
cargo install --path teos
git clone https://github.com/talaia-labs/rust-teos.git
cd rust-teos
cargo install --locked --path teos
```
You can run tests with:
@ -14,4 +14,8 @@ You can run tests with:
cargo test
```
Please refer to the cargo documentation for more detailed instructions.
Please refer to the cargo documentation for more detailed instructions.
# Systemd setup for backend
Refer to [contrib](contrib/init/README.md) for a detailed explanation of how to set up your systemd service for `teosd`.

View file

@ -1,13 +1,16 @@
**THIS IS CURRENTLY WIP**
# The Eye of Satoshi (rust-teos)
The Eye of Satoshi is a Lightning watchtower compliant with [BOLT13](https://github.com/sr-gi/bolt13), written in Rust.
`rust-teos` consists in two main crates:
[![discord](https://img.shields.io/discord/991334710611550208?logo=discord&style=plastic)](https://discord.gg/EyVbrNMDUP)
[![build](https://img.shields.io/github/actions/workflow/status/talaia-labs/rust-teos/build.yaml?logo=github&style=plastic)](https://github.com/talaia-labs/rust-teos/actions/workflows/build.yaml)
[![release](https://img.shields.io/github/v/release/talaia-labs/rust-teos?style=plastic)](https://github.com/talaia-labs/rust-teos/releases/latest)
- `teos`: including the tower's main functionality (server-side) and a CLI.
- `teos-common`: including shared functionality between server and client side (useful to build a client).
`rust-teos` consists of two main crates:
- `teos`: including the tower's main functionality (server-side) and a CLI. Compiling this crate will generate two binaries: `teosd` and `teos-cli`.
- `teos-common`: including shared functionality between server and client-side (useful to build a client).
## Dependencies
@ -18,8 +21,9 @@ Refer to [INSTALL.md](INSTALL.md)
## Running TEOS
Make sure bitcoind is running before running `rust-teos` (it will fail at startup if it cannot connect to bitcoind). You can find
[here](DEPENDENCIES.md#installing-bitcoind) a sample config file.
Make sure `bitcoind` is running before running `teosd` (it will fail at startup if it cannot connect to `bitcoind`). [Here](DEPENDENCIES.md#installing-bitcoind) you can find a sample bitcoin.conf.
Please see [Docker instructions](docker/README.md) for instructions on how to set up `teosd` in Docker.
### Starting the tower daemon ♖
@ -31,31 +35,31 @@ teosd
### Configuration file and command line parameters
`rust-teos` comes with a default configuration that can be found at [teos/src/config.rs](teos/src/config.rs).
`teosd` comes with a default configuration that can be found at [teos/src/config.rs](teos/src/config.rs).
The configuration includes, amongst others, where your data folder is placed, what network it connects to, etc.
To change the configuration defaults you can:
- Define a configuration file named `teos.toml` following the template (check [teos/src/conf_template.toml](teos/src/conf_template.toml)) and place it in the `data_dir` (that defaults to `~/.teos/`)
- Define a configuration file named `teos.toml` following the template (check [conf_template.toml](teos/src/conf_template.toml)) and place it in the `data_dir` (that defaults to `~/.teos/`).
and / or
and/or
- Add some global options when running the daemon (run `teosd -h` for more info).
### Passing command line options to `teosd`
### Passing command-line options to `teosd`
Some configuration options can also be passed as options when running `teosd`. We can, for instance, change the tower data directory as follows:
Some configuration options can also be specified when running `teosd`. We can, for instance, change the tower data directory as follows:
```
teosd --datadir=<path_to_dir>
```
### Running TEOS in another network
### Running `teosd` in another network
By default, `rust-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.
By default, `teosd` 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 `teosd` does not find a `bitcoind` node running in the same network that it is set to run, it will refuse to run.
The configuration file option to change the network where `teos` will run is `btc_network`:
The configuration file option to change the network where `teosd` will run is `btc_network`:
```
btc_network = mainnet
@ -67,17 +71,38 @@ For regtest, it should look like:
btc_network = regtest
```
### Running `teosd` with Tor
This requires a Tor daemon running on the same machine as `teosd` and a control port open on that daemon.
Download Tor from the [torproject site](https://www.torproject.org/download/).
To open Tor's control port, you add the following to the Tor config file ([source](https://2019.www.torproject.org/docs/faq.html.en#torrc)):
```
## The port on which Tor will listen for local connections from Tor
## controller applications, as documented in control-spec.txt.
ControlPort 9051
## If you enable the controlport, be sure to enable one of these
## authentication methods, to prevent attackers from accessing it.
CookieAuthentication 1
CookieAuthFileGroupReadable 1
```
Once the Tor daemon is running, and the control port is open, make sure to enable `--torsupport` when running `teosd`.
### Tower id and signing key
`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. Notice that once a key is overwritten you won't be able to use the previous key again*.
`teosd` 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 `teosd` with the `--overwritekey` flag. Notice that once a key is overwritten you won't be able to use the previous key again*.
\* Old keys are actually kept in the tower's database as a fail safe in case you overwrite them by mistake. However, there is no automated way of switching back to and old key. Feel free to open an issue if you overwrote your key by mistake and need support to recover it.
\* Old keys are actually kept in the tower's database as a fail-safe in case you overwrite them by mistake. However, there is no automated way of switching back to an old key. Feel free to open an issue if you overwrote your key by mistake and need support to recover it.
## Interacting with a TEOS Instance
## Interacting with a TEOS instance
You can interact with a `teos` instance (either run by yourself or someone else) by using `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).
You can interact with a `teosd` instance (either run by yourself or someone else) by using `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).
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.
While `teos-cli` works independently of `teosd`, 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.
For help on the available arguments and commands, you can run:
@ -85,9 +110,18 @@ For help on the available arguments and commands, you can run:
teos-cli -h
```
## Interacting with TEOS as a client
### Running teos-cli remotely
FIXME: Add client and docs
To run `teos-cli` remotely, you'll need to take one extra step. When `teosd` is started up, self-signed certificates are automatically generated for a user to make a secure connection to the remote TEOS watchtower. When the CLI is run locally, it knows where to find these files. But if run remotely, these files need to be copied over to the machine where the CLI is being run.
The files are generated to the data directory (by default stored at `~/.teos/`). To run remotely, users need to copy the `client.pem`, `client-key.pem`, and `ca.pem` files to the corresponding watchtower data directory on the machine where the CLI is being run. That is, by default, to `~/.teos/` on the remote machine.
## Interacting with TEOS as a client
### TEOS clients
Here is a list of the available clients for `teos`:
- [watchtower-client for CLN](watchtower-plugin/)
## Contributing
Refer to [CONTRIBUTING.md](CONTRIBUTING.md)

10
coffee.yml Normal file
View file

@ -0,0 +1,10 @@
---
plugin:
name: rust-teos
version: 0.2.0
lang: rust
install: |
cargo build --release --locked --package watchtower-plugin
cp target/release/watchtower-client .
cargo clean
main: watchtower-client

42
contrib/init/README.md Normal file
View file

@ -0,0 +1,42 @@
**This document guides you into how to set-up a systemd service to run `teosd`.**
Since the teos service requires bitcoin to run, it is strongly recommended to also create a [system service for bitcoin](https://github.com/bitcoin/bitcoin/blob/master/contrib/init/bitcoind.service).
Once you have set the bitcoin service, proceed to copy [teosd.service](teosd.service) to the systemd folder, that is, if running from this folder:
```
cp teosd.service /etc/systemd/system
```
You can also create a file called `teosd.service` in the systemd folder and copy the content of [teosd.service](teosd.service) to it:
```
sudo vim /etc/systemd/system/teosd.service
```
Notice the provided service file is using `teos` both as user and group for the service, so you may want to update that if that is not the configuration you are intending to use. Here are the lines to be updated:
```
[Service]
ExecStart=/home/<user>/.cargo/bin/teosd
SyslogIdentifier=<user>
# Directory creation and permissions
####################################
User=<user>
Group=<group>
```
The next step is enabling the service. You can do so by running:
```
sudo systemctl enable teosd.service
```
Finally, you can start the service by running:
```
sudo systemctl start teosd.service
```
From that point on, the tower will be run every time your system is turned on, and restarted if needed.

View file

@ -0,0 +1,46 @@
[Unit]
Description=The Eye of Satoshi daemon
Requires=bitcoind.service
After=bitcoind.service
Wants=network-online.target
After=network-online.target
[Service]
ExecStart=/home/teos/.cargo/bin/teosd
StandardOutput=journal
StandardError=journal
SyslogIdentifier=teos
# Process management
####################
Type=simple
Restart=on-failure
TimeoutSec=300
RestartSec=60
# Directory creation and permissions
####################################
User=teos
Group=teos
# Hardening measures
####################
# Provide a private /tmp and /var/tmp.
PrivateTmp=true
# Mount /usr, /boot/ and /etc read-only for the process.
ProtectSystem=full
# Disallow the process and all of its children to gain
# new privileges through execve().
NoNewPrivileges=true
# Use a new /dev namespace only populated with API pseudo devices
# such as /dev/null, /dev/zero and /dev/random.
PrivateDevices=true
# Deny the creation of writable and executable memory mappings.
MemoryDenyWriteExecute=true
[Install]
WantedBy=multi-user.target

49
docker/Dockerfile Normal file
View file

@ -0,0 +1,49 @@
# Use the rust image as the base image for the build stage
FROM rust:latest AS builder
# Copy the rust-teos source code
COPY . /tmp/rust-teos
# Install the dependencies required for building rust-teos
RUN apt-get update\
&& apt-get -y --no-install-recommends install libffi-dev libssl-dev musl-tools pkg-config
RUN cd /tmp/rust-teos \
&& rustup target add x86_64-unknown-linux-musl \
# Rustfmt is needed to format the grpc stubs generated by tonic
&& rustup component add rustfmt \
# Cross compile with musl as the target, so teosd can run on alpine
&& RUSTFLAGS='-C target-feature=+crt-static' cargo build --manifest-path=teos/Cargo.toml --locked --release --target x86_64-unknown-linux-musl
# Use a new stage with a smaller base image to reduce image size
FROM alpine:latest
RUN apk update && apk upgrade
# UID and GID for the teosd user
ENV TEOS_UID=1001 TEOS_GID=1001
# Copy the teos binaries from the build stage to the new stage
COPY --from=builder \
/tmp/rust-teos/target/x86_64-unknown-linux-musl/release/teosd \
/tmp/rust-teos/target/x86_64-unknown-linux-musl/release/teos-cli /usr/local/bin/
# Copy the entrypoint script to the container
COPY docker/entrypoint.sh /entrypoint.sh
# Set the entrypoint script as executable and add running user
RUN chmod +x /entrypoint.sh \
&& addgroup -g ${TEOS_GID} -S teos \
&& adduser -S -G teos -u ${TEOS_UID} teos
# Expose the default port used by teosd
EXPOSE 9814/tcp
# Switch user so that we don't run stuff as root
USER teos
# Create the teos data directory
RUN mkdir /home/teos/.teos
# Start teosd when the container starts
ENTRYPOINT [ "/entrypoint.sh" ]

113
docker/README.md Normal file
View file

@ -0,0 +1,113 @@
## Running `teosd` in a docker container
A `teos` image can be built from the Dockerfile located in `docker`. You can create the image by running:
cd rust-teos
docker build -f docker/Dockerfile -t teos .
Then we can create a container by running:
docker run -it teos
One way to feed `teos` custom config options is to set environment variables:
docker run -it -e <ENV_VARIABLES> teos
Notice that the ENV variables are optional, if unset the corresponding default setting is used. The following ENVs are available:
```
- API_BIND=<teos_api_hostname>
- API_PORT=<teos_api_port>
- RPC_BIND=<teos_rpc_hostname>
- RPC_PORT=<teos_rpc_port>
- BTC_NETWORK=<btc_network>
- BTC_RPC_CONNECT=<btc_node_hostname>
- BTC_RPC_PORT=<btc_node_port>
- BTC_RPC_USER=<btc_rpc_username>
- BTC_RPC_PASSWORD=<btc_rpc_password>
# The following options can be set turned on by setting them to "true"
- DEBUG=<debug_bool>
- DEPS_DEBUG=<deps_debug_bool>
- OVERWRITE_KEY=<overwrite_key_bool>
- FORCE_UPDATE=<force_update_bool>
```
### Volume persistence
You may also want to run docker with a volume, so you can have data persistence in `teosd` databases and keys.
If so, run:
docker volume create teos-data
And add the the mount parameter to `docker run`:
-v teos-data:/home/teos/.teos
If you are running `teosd` and `bitcoind` in the same machine, continue reading for how to create the container based on your OS.
### `bitcoind` running on the same machine (UNIX)
The easiest way to run both together in the same machine using UNIX is to set the container to use the host network.
For example, if both `teosd` and `bitcoind` are running on default settings, run:
```
docker run \
--network=host \
--name teos \
-v teos-data:/home/teos/.teos \
-e BTC_RPC_USER=<btc_rpc_username> \
-e BTC_RPC_PASSWORD=<btc_rpc_password> \
-it teos
```
Notice that you may still need to set your RPC authentication details, since, hopefully, your credentials won't match the `teosd` defaults.
### `bitcoind` running on the same machine (OSX or Windows)
Docker for OSX and Windows does not allow to use the host network (nor to use the `docker0` bridge interface). To work around this
you can use the special `host.docker.internal` domain:
```
docker run \
-p 9814:9814 \
-p 8814:8814 \
--name teos \
-v teos-data:/home/teos/.teos \
-e BTC_RPC_CONNECT=host.docker.internal \
-e BTC_RPC_USER=<btc_rpc_username> \
-e BTC_RPC_PASSWORD=<btc_rpc_password> \
-e API_BIND=0.0.0.0 \
-e RPC_BIND=0.0.0.0 \
-it teos
```
Notice that we also needed to add `API_BIND=0.0.0.0` and `RPC_BIND=0.0.0.0` to bind the API to all interfaces of the container.
Otherwise it will bind to `localhost` and we won't be able to send requests to the tower from the host.
### Interacting with a TEOS instance
Once our `teos` instance is running in the container, we can interact with it using `teos-cli`. We have two main ways of doing so:
1) You can open a shell to the Docker instance by calling:
`docker exec -it <CONTAINER_NAME> sh`
Then you can use the `teos-cli` binary from inside the container as you would use it from your host machine.
2) Using `teos-cli` remotely (assuming you have it installed in the source machine) and pointing to the container. To do so, you will need to copy over the necessary credentials to the host machine. To do so, you can follow the instructions in [the main README](https://github.com/talaia-labs/rust-teos/blob/master/README.md#running-teos-cli-remotely).
### Plugging in Tor
You may have noticed, in the above section where the environment variables are covered, that the Tor options are nowhere to be found. That's because these instructions assume that users will likely be setting up Tor in another container.
On the machine where you have Tor running, you can follow [these instructions](https://community.torproject.org/onion-services/setup/) for setting up a hidden service manually.
For instance, if you're running `teosd` in a Docker container on the same machine as where Tor is running, you can create a hidden service from the host machine to hide the IP of the `teosd` API (listening on port 9814 for example). If you're using Linux, you can do so by editing your `torrc` file on the host machine with the below option:
```
HiddenServiceDir /var/lib/tor/teosd # Path for Linux. This may differ depending on your OS.
HiddenServicePort 9814 127.0.0.1:9814
```
Then restart Tor.
If all works correctly, the hidden service public key will be located in the `HiddenServiceDir` you set above, in the file called `hostname`.

66
docker/entrypoint.sh Executable file
View file

@ -0,0 +1,66 @@
#!/bin/sh
# Define the start command
START_COMMAND="teosd"
# Set the API bind address
if [[ ! -z ${API_BIND} ]]; then
START_COMMAND="$START_COMMAND --apibind $API_BIND"
fi
# Set the API port
if [[ ! -z ${API_PORT} ]]; then
START_COMMAND="$START_COMMAND --apiport $API_PORT"
fi
# Set the RPC bind address
if [[ ! -z ${RPC_BIND} ]]; then
START_COMMAND="$START_COMMAND --rpcbind $RPC_BIND"
fi
# Set the RPC port
if [[ ! -z ${RPC_PORT} ]]; then
START_COMMAND="$START_COMMAND --rpcport $RPC_PORT"
fi
# Set the Bitcoin network
if [[ ! -z ${BTC_NETWORK} ]]; then
START_COMMAND="$START_COMMAND --btcnetwork $BTC_NETWORK"
fi
# Set the Bitcoin RPC credentials
if [[ ! -z ${BTC_RPC_USER} ]]; then
START_COMMAND="$START_COMMAND --btcrpcuser $BTC_RPC_USER"
fi
if [[ ! -z ${BTC_RPC_PASSWORD} ]]; then
START_COMMAND="$START_COMMAND --btcrpcpassword $BTC_RPC_PASSWORD"
fi
# Set the Bitcoin RPC connection details
if [[ ! -z ${BTC_RPC_CONNECT} ]]; then
START_COMMAND="$START_COMMAND --btcrpcconnect $BTC_RPC_CONNECT"
fi
if [[ ! -z ${BTC_RPC_PORT} ]]; then
START_COMMAND="$START_COMMAND --btcrpcport $BTC_RPC_PORT"
fi
if [ "${DEBUG}" == "true" ]; then
START_COMMAND="$START_COMMAND --debug"
fi
if [ "${DEPS_DEBUG}" == "true" ]; then
START_COMMAND="$START_COMMAND --depsdebug"
fi
if [ "${OVERWRITE_KEY}" == "true" ]; then
START_COMMAND="$START_COMMAND --overwritekey"
fi
if [ "${FORCE_UPDATE}" == "true" ]; then
START_COMMAND="$START_COMMAND --forceupdate"
fi
# Start the TEOS daemon
$START_COMMAND

116
flake.lock generated Normal file
View file

@ -0,0 +1,116 @@
{
"nodes": {
"crane": {
"locked": {
"lastModified": 1758758545,
"narHash": "sha256-NU5WaEdfwF6i8faJ2Yh+jcK9vVFrofLcwlD/mP65JrI=",
"owner": "ipetkov",
"repo": "crane",
"rev": "95d528a5f54eaba0d12102249ce42f4d01f4e364",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"fenix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1758782550,
"narHash": "sha256-olCvyP5r6+HQTl2EUudtjlA5UammsBpkzAl0l9+utZc=",
"owner": "nix-community",
"repo": "fenix",
"rev": "32f4e350c03cc5762be811e9c700e8696cd13c02",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "fenix",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1758589230,
"narHash": "sha256-zMTCFGe8aVGTEr2RqUi/QzC1nOIQ0N1HRsbqB4f646k=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "d1d883129b193f0b495d75c148c2c3a7d95789a0",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.05",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"crane": "crane",
"fenix": "fenix",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1758620797,
"narHash": "sha256-Ly4rHgrixFMBnkbMursVt74mxnntnE6yVdF5QellJ+A=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "905641f3520230ad6ef421bcf5da9c6b49f2479b",
"type": "github"
},
"original": {
"owner": "rust-lang",
"ref": "nightly",
"repo": "rust-analyzer",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

114
flake.nix Normal file
View file

@ -0,0 +1,114 @@
{
description = "Build teos (The Eye of Satoshi) server and plugin";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
crane.url = "github:ipetkov/crane";
fenix = {
url = "github:nix-community/fenix";
inputs.nixpkgs.follows = "nixpkgs";
};
flake-utils.url = "github:numtide/flake-utils";
};
outputs =
{
nixpkgs,
crane,
fenix,
flake-utils,
...
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
inherit (pkgs) lib;
craneLib = (crane.mkLib pkgs).overrideToolchain fenix.packages.${system}.stable.minimalToolchain;
env = {
PROTOC = "${pkgs.protobuf}/bin/protoc";
PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig";
LD_LIBRARY_PATH = lib.makeLibraryPath [ pkgs.openssl.out ];
};
commonArgs = {
inherit env;
strictDeps = true;
nativeBuildInputs = [
pkgs.pkg-config
pkgs.rustfmt # needed for tonic build
pkgs.cacert
pkgs.openssl.dev
];
buildInputs =
[ ]
++ lib.optionals pkgs.stdenv.isDarwin [
# Additional darwin specific inputs can be set here
pkgs.libiconv
];
};
fileSetForCrate =
crate:
lib.fileset.toSource {
root = ./.;
fileset = lib.fileset.unions [
./Cargo.toml
./Cargo.lock
./teos-common
./teos
./watchtower-plugin
crate
];
};
plugin = craneLib.buildPackage (
commonArgs
// {
pname = "watchtower-plugin";
cargoExtraArgs = "-p watchtower-plugin";
src = fileSetForCrate ./watchtower-plugin;
inherit (craneLib.crateNameFromCargoToml { cargoToml = ./watchtower-plugin/Cargo.toml; }) version;
}
);
teos = craneLib.buildPackage (
commonArgs
// {
pname = "teos";
cargoExtraArgs = "-p teos";
src = fileSetForCrate ./teos;
inherit (craneLib.crateNameFromCargoToml { cargoToml = ./teos/Cargo.toml; }) version;
}
);
in
{
packages = {
inherit plugin teos;
default = teos;
};
apps = {
plugin = flake-utils.lib.mkApp { drv = plugin; };
teos = flake-utils.lib.mkApp { drv = teos; };
};
formatter = pkgs.nixfmt-tree;
checks = {
inherit teos plugin;
};
devShells.default = craneLib.devShell {
inherit env;
packages = commonArgs.buildInputs ++ commonArgs.nativeBuildInputs;
};
}
);
}

6
rust-toolchain.toml Normal file
View file

@ -0,0 +1,6 @@
[toolchain]
channel = "1.81.0"
components = [
"rustfmt",
"clippy",
]

View file

@ -1,19 +1,27 @@
[package]
name = "teos-common"
version = "0.0.1"
version = "0.2.0"
authors = ["Sergi Delgado Segura <sergi.delgado.s@gmail.com>"]
edition = "2018"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
# General
hex = "0.4.3"
hex = { version = "0.4.3", features = [ "serde" ] }
prost = "0.12"
rusqlite = { version = "0.26.0", features = [ "bundled", "limits" ] }
serde = "1.0.130"
serde_json = "1.0"
tonic = "0.11"
# Crypto
rand = "0.8.4"
chacha20poly1305 = "0.8.0"
# Bitcoin and Lightning
bitcoin = "0.27"
lightning = "0.0.99"
bitcoin = { version = "0.32.0", features = [ "serde" ] }
lightning = "0.1.0"
[build-dependencies]
tonic-build = "0.11"

30
teos-common/build.rs Normal file
View file

@ -0,0 +1,30 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_build::configure()
.type_attribute(".", "#[derive(serde::Serialize, serde::Deserialize)]")
.type_attribute("AppointmentData.appointment_data", "#[serde(untagged)]")
.field_attribute("AppointmentData.appointment_data", "#[serde(flatten)]")
.field_attribute("appointment_data", "#[serde(rename = \"appointment\")]")
.field_attribute("user_id", "#[serde(with = \"hex::serde\")]")
.field_attribute("locator", "#[serde(with = \"hex::serde\")]")
.field_attribute(
"locators",
"#[serde(with = \"crate::ser::serde_vec_bytes\")]",
)
.field_attribute("encrypted_blob", "#[serde(with = \"hex::serde\")]")
.field_attribute("dispute_txid", "#[serde(with = \"crate::ser::serde_be\")]")
.field_attribute("penalty_txid", "#[serde(with = \"crate::ser::serde_be\")]")
.field_attribute("penalty_rawtx", "#[serde(with = \"hex::serde\")]")
.field_attribute(
"GetAppointmentResponse.status",
"#[serde(with = \"crate::ser::serde_status\")]",
)
.compile(
&[
"proto/common/teos/v2/appointment.proto",
"proto/common/teos/v2/user.proto",
],
&["proto/common/teos/v2"],
)?;
Ok(())
}

View file

@ -0,0 +1,75 @@
syntax = "proto3";
package common.teos.v2;
message Appointment {
/*
Contains the basic information about an appointment (Watcher) and it's used for messages like
AddAppointmentRequest or encapsulated inside AppointmentData for GetAppointmentResponse
*/
bytes locator = 1;
bytes encrypted_blob = 2;
uint32 to_self_delay = 3;
}
message Tracker {
// It's the equivalent of an appointment message from data held by the Responder.
bytes dispute_txid = 1;
bytes penalty_txid = 2;
bytes penalty_rawtx = 3;
}
message AppointmentData {
/*
Encapsulates the data for a GetAppointmentResponse, given it can be an appointment (data is on the Watcher) or a
tracker (data is on the Responder).
*/
oneof appointment_data {
Appointment appointment = 1;
Tracker tracker = 2;
}
}
message AddAppointmentRequest {
// Request to add an appointment to the backend, contains the appointment data and the user signature.
Appointment appointment = 1;
string signature = 2;
}
message AddAppointmentResponse {
/*
Response to an AddAppointmentRequest, contains the locator to identify the added appointment, the tower signature,
the block at which the tower has started (or will start) watching for the appointment, and the updated subscription
information.
*/
bytes locator = 1;
uint32 start_block = 2;
string signature = 3;
uint32 available_slots = 4;
uint32 subscription_expiry = 5;
}
message GetAppointmentRequest {
// Request to get information about an appointment. Contains the appointment locator and a signature by the user.
bytes locator = 1;
string signature = 2;
}
message GetAppointmentResponse {
// Response to a GetAppointmentRequest. Contains the appointment data encapsulated in an AppointmentData message.
AppointmentData appointment_data = 1;
enum AppointmentStatus {
NOT_FOUND = 0;
BEING_WATCHED = 1;
DISPUTE_RESPONDED = 2;
}
AppointmentStatus status = 2;
}

View file

@ -0,0 +1,32 @@
syntax = "proto3";
package common.teos.v2;
message RegisterRequest {
// Requests a user registration with the tower. Contains the user id in the form of a compressed ECDSA public key.
bytes user_id = 1;
}
message RegisterResponse {
// Response to a RegisterRequest, contains the registration information alongside the tower signature of the agreement.
bytes user_id = 1;
uint32 available_slots = 2;
uint32 subscription_start = 3;
uint32 subscription_expiry = 4;
string subscription_signature = 5;
}
message GetSubscriptionInfoRequest {
// Request to get a specific user's subscription info.
string signature = 1;
}
message GetSubscriptionInfoResponse {
// Response with the information the tower has about a specific user
uint32 available_slots = 1;
uint32 subscription_expiry = 2;
repeated bytes locators = 3;
}

View file

@ -1,15 +1,17 @@
//! Logic related to appointments shared between users and the towers.
use hex;
use serde::{Deserialize, Serialize};
use std::array::TryFromSliceError;
use std::{convert::TryInto, fmt};
use bitcoin::Txid;
use crate::protos as msgs;
pub const LOCATOR_LEN: usize = 16;
/// User identifier for appointments.
#[derive(Debug, Eq, PartialEq, Copy, Clone, Hash)]
#[derive(Debug, Eq, PartialEq, Copy, Clone, Hash, Serialize, Deserialize)]
pub struct Locator([u8; LOCATOR_LEN]);
impl Locator {
@ -19,36 +21,42 @@ impl Locator {
}
/// Encodes a locator into its byte representation.
pub fn serialize(&self) -> Vec<u8> {
pub fn to_vec(&self) -> Vec<u8> {
self.0.to_vec()
}
/// Builds a locator from its byte representation.
pub fn deserialize(data: &[u8]) -> Result<Self, TryFromSliceError> {
pub fn from_slice(data: &[u8]) -> Result<Self, TryFromSliceError> {
data.try_into().map(Self)
}
}
impl std::str::FromStr for Locator {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let raw_locator = hex::decode(s).map_err(|_| "Locator is not hex encoded")?;
Locator::deserialize(&raw_locator)
.map_err(|_| "Locator cannot be built from the given data".into())
}
}
impl fmt::Display for Locator {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", hex::encode(self.serialize()))
write!(f, "{}", hex::encode(self.to_vec()))
}
}
impl AsRef<[u8]> for Locator {
fn as_ref(&self) -> &[u8] {
&self.0
}
}
impl hex::FromHex for Locator {
type Error = String;
fn from_hex<T: AsRef<[u8]>>(hex: T) -> Result<Self, Self::Error> {
let raw_locator = hex::decode(hex).map_err(|_| "Locator is not hex encoded")?;
Locator::from_slice(&raw_locator)
.map_err(|_| "Locator cannot be built from the given data".into())
}
}
/// Contains data regarding an appointment between a client and the tower.
///
/// An appointment is requested for every new channel update.
#[derive(Debug, Eq, PartialEq, Clone)]
#[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize)]
pub struct Appointment {
/// The user identifier for the appointment.
pub locator: Locator,
@ -62,6 +70,7 @@ pub struct Appointment {
}
/// Represents all the possible states of an appointment in the tower, or in a response to a client request.
#[derive(Serialize, Deserialize, Debug)]
pub enum AppointmentStatus {
NotFound = 0,
BeingWatched = 1,
@ -86,7 +95,7 @@ impl std::str::FromStr for AppointmentStatus {
"being_watched" => Ok(AppointmentStatus::BeingWatched),
"dispute_responded" => Ok(AppointmentStatus::DisputeResponded),
"not_found" => Ok(AppointmentStatus::NotFound),
_ => Err(format!("Unknown status: {}", s)),
_ => Err(format!("Unknown status: {s}")),
}
}
}
@ -98,7 +107,7 @@ impl fmt::Display for AppointmentStatus {
AppointmentStatus::DisputeResponded => "dispute_responded",
AppointmentStatus::NotFound => "not_found",
};
write!(f, "{}", s)
write!(f, "{s}")
}
}
@ -118,10 +127,27 @@ impl Appointment {
/// `locator || encrypted_blob || to_self_delay`
///
/// All values are big endian.
pub fn serialize(&self) -> Vec<u8> {
let mut result = self.locator.serialize();
pub fn to_vec(&self) -> Vec<u8> {
let mut result = self.locator.to_vec();
result.extend(&self.encrypted_blob);
result.extend(self.to_self_delay.to_be_bytes().to_vec());
result
}
}
impl From<Appointment> for msgs::Appointment {
fn from(a: Appointment) -> Self {
Self {
locator: a.locator.to_vec(),
encrypted_blob: a.encrypted_blob.clone(),
to_self_delay: a.to_self_delay,
}
}
}
/// Computes the number of slots an appointment takes from a user subscription.
///
/// This is based on the [encrypted_blob](Appointment::encrypted_blob) size and the slot size that was defined by the [Gatekeeper](crate::gatekeeper::Gatekeeper).
pub fn compute_appointment_slots(blob_size: usize, blob_max_size: usize) -> u32 {
(blob_size as f32 / blob_max_size as f32).ceil() as u32
}

View file

@ -1,14 +1,13 @@
//! Cryptography module, used in the interaction between users and towers.
use rand::distributions::Uniform;
use rand::Rng;
use chacha20poly1305::aead::{Aead, NewAead};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
use rand::distributions::Uniform;
use rand::Rng;
use bitcoin::consensus;
use bitcoin::hashes::{sha256, Hash};
use bitcoin::secp256k1::{Error, PublicKey, Secp256k1, SecretKey};
use bitcoin::util::psbt::serialize::{Deserialize, Serialize};
use bitcoin::{Transaction, Txid};
use lightning::util::message_signing;
@ -20,7 +19,7 @@ pub enum DecryptingError {
}
/// Shadows [message_signing::sign].
pub fn sign(msg: &[u8], sk: &SecretKey) -> Result<String, Error> {
pub fn sign(msg: &[u8], sk: &SecretKey) -> String {
message_signing::sign(msg, sk)
}
@ -47,11 +46,11 @@ pub fn encrypt(
) -> Result<Vec<u8>, chacha20poly1305::aead::Error> {
// Defaults is [0; 12]
let nonce = Nonce::default();
let _k = sha256::Hash::hash(secret);
let key = Key::from_slice(&_k);
let k = sha256::Hash::hash(secret.as_byte_array());
let key = Key::from_slice(k.as_byte_array());
let cypher = ChaCha20Poly1305::new(key);
cypher.encrypt(&nonce, message.serialize().as_ref())
cypher.encrypt(&nonce, consensus::serialize(message).as_ref())
}
/// Decrypts an encrypted blob of data using `chacha20poly1305` and a given secret.
@ -64,13 +63,13 @@ pub fn encrypt(
pub fn decrypt(encrypted_blob: &[u8], secret: &Txid) -> Result<Transaction, DecryptingError> {
// Defaults is [0; 12]
let nonce = Nonce::default();
let _k = sha256::Hash::hash(secret);
let key = Key::from_slice(&_k);
let k = sha256::Hash::hash(secret.as_byte_array());
let key = Key::from_slice(k.as_byte_array());
let cypher = ChaCha20Poly1305::new(key);
match cypher.decrypt(&nonce, encrypted_blob.as_ref()) {
Ok(tx_bytes) => Transaction::deserialize(&tx_bytes).map_err(DecryptingError::Encode),
Ok(tx_bytes) => consensus::deserialize(&tx_bytes).map_err(DecryptingError::Encode),
Err(e) => Err(DecryptingError::AED(e)),
}
}
@ -81,17 +80,13 @@ pub fn decrypt(encrypted_blob: &[u8], secret: &Txid) -> Result<Transaction, Decr
pub fn get_random_bytes(size: usize) -> Vec<u8> {
let mut rng = rand::thread_rng();
let uniform_u8 = Uniform::new(u8::MIN, u8::MAX);
let v: Vec<u8> = (&mut rng).sample_iter(uniform_u8).take(size).collect();
v
(&mut rng).sample_iter(uniform_u8).take(size).collect()
}
/// Gets a key pair generated in a pseudorandom way.
pub fn get_random_keypair() -> (SecretKey, PublicKey) {
let raw_sk = get_random_bytes(32);
loop {
if let Ok(sk) = SecretKey::from_slice(&raw_sk) {
if let Ok(sk) = SecretKey::from_slice(&get_random_bytes(32)) {
return (sk, PublicKey::from_secret_key(&Secp256k1::new(), &sk));
}
}
@ -99,8 +94,11 @@ pub fn get_random_keypair() -> (SecretKey, PublicKey) {
#[cfg(test)]
mod tests {
use std::str::FromStr;
use super::*;
use bitcoin::{hashes::hex::FromHex, util::psbt::serialize::Deserialize};
use bitcoin::consensus;
use bitcoin::hashes::hex::FromHex;
const HEX_TX: &str = "010000000001010000000000000000000000000000000000000000000000000000000000000000ffffffff54038e830a1b4d696e656420627920416e74506f6f6c373432c2005b005e7a0ae3fabe6d6d7841cd582ead8ea5dd8e3de1173cae6fcd2a53c7362ebb7fb6f815604fe07cbe0200000000000000ac0e060005f90000ffffffff04d9476026000000001976a91411dbe48cc6b617f9c6adaf4d9ed5f625b1c7cb5988ac0000000000000000266a24aa21a9ed7248c6efddd8d99bfddd7f499f0b915bffa8253003cc934df1ff14a81301e2340000000000000000266a24b9e11b6d7054937e13f39529d6ad7e685e9dd4efa426f247d5f5a5bed58cdddb2d0fa60100000000000000002b6a2952534b424c4f434b3a054a68aa5368740e8b3e3c67bce45619c2cfd07d4d4f0936a5612d2d0034fa0a0120000000000000000000000000000000000000000000000000000000000000000000000000";
const HEX_TXID: &str = "d6ac4a5e61657c4c604dcde855a1db74ec6b3e54f32695d72c5e11c7761ea1b4";
@ -111,17 +109,17 @@ mod tests {
let expected_enc_blob = Vec::from_hex(ENC_BLOB).unwrap();
let tx_bytes = Vec::from_hex(HEX_TX).unwrap();
let tx = Transaction::deserialize(&tx_bytes).unwrap();
let txid = Txid::from_hex(HEX_TXID).unwrap();
let tx: Transaction = consensus::deserialize(&tx_bytes).unwrap();
let txid = bitcoin::Txid::from_str(HEX_TXID).unwrap();
assert_eq!(encrypt(&tx, &txid).unwrap(), expected_enc_blob);
}
#[test]
fn test_decrypt() {
let expected_tx = Transaction::deserialize(&Vec::from_hex(HEX_TX).unwrap()).unwrap();
let expected_tx = consensus::deserialize(&Vec::from_hex(HEX_TX).unwrap()).unwrap();
let encrypted_blob = Vec::from_hex(ENC_BLOB).unwrap();
let txid = Txid::from_hex(HEX_TXID).unwrap();
let txid = bitcoin::Txid::from_str(HEX_TXID).unwrap();
assert_eq!(decrypt(&encrypted_blob, &txid).unwrap(), expected_tx);
}
}

72
teos-common/src/dbm.rs Normal file
View file

@ -0,0 +1,72 @@
//! Logic related to a common database manager, component in charge of persisting data on disk. This is the base of more complex managers
//! that can be used by both clients and towers.
//!
use rusqlite::ffi::{SQLITE_CONSTRAINT_FOREIGNKEY, SQLITE_CONSTRAINT_PRIMARYKEY};
use rusqlite::{Connection, Error as SqliteError, ErrorCode, Params};
/// Packs the errors than can raise when interacting with the underlying database.
#[derive(Debug)]
pub enum Error {
AlreadyExists,
MissingForeignKey,
MissingField,
NotFound,
Unknown(SqliteError),
}
pub trait DatabaseConnection {
fn get_connection(&self) -> &Connection;
fn get_mut_connection(&mut self) -> &mut Connection;
}
pub trait DatabaseManager: Sized {
fn create_tables(&mut self, tables: Vec<&str>) -> Result<(), SqliteError>;
fn store_data<P: Params>(&self, query: &str, params: P) -> Result<(), Error>;
fn remove_data<P: Params>(&self, query: &str, params: P) -> Result<(), Error>;
fn update_data<P: Params>(&self, query: &str, params: P) -> Result<(), Error>;
}
impl<T: DatabaseConnection> DatabaseManager for T {
/// Creates the database tables if not present.
fn create_tables(&mut self, tables: Vec<&str>) -> Result<(), SqliteError> {
let tx = self.get_mut_connection().transaction().unwrap();
for table in tables.iter() {
tx.execute(table, [])?;
}
tx.commit()
}
/// Generic method to store data into the database.
fn store_data<P: Params>(&self, query: &str, params: P) -> Result<(), Error> {
match self.get_connection().execute(query, params) {
Ok(_) => Ok(()),
Err(e) => match e {
SqliteError::SqliteFailure(ie, _) => match ie.code {
ErrorCode::ConstraintViolation => match ie.extended_code {
SQLITE_CONSTRAINT_FOREIGNKEY => Err(Error::MissingForeignKey),
SQLITE_CONSTRAINT_PRIMARYKEY => Err(Error::AlreadyExists),
_ => Err(Error::Unknown(e)),
},
_ => Err(Error::Unknown(e)),
},
_ => Err(Error::Unknown(e)),
},
}
}
/// Generic method to remove data from the database.
fn remove_data<P: Params>(&self, query: &str, params: P) -> Result<(), Error> {
match self.get_connection().execute(query, params).unwrap() {
0 => Err(Error::NotFound),
_ => Ok(()),
}
}
/// Generic method to update data from the database.
fn update_data<P: Params>(&self, query: &str, params: P) -> Result<(), Error> {
// Updating data is fundamentally the same as deleting it in terms of interface.
// A query is sent and either no row is modified or some rows are
self.remove_data(query, params)
}
}

View file

@ -2,30 +2,45 @@
//!
//! Functionality shared between users and towers.
// FIXME: This is a temporary fix. See https://github.com/tokio-rs/prost/issues/661
#[allow(clippy::derive_partial_eq_without_eq)]
pub mod protos {
tonic::include_proto!("common.teos.v2");
}
pub mod appointment;
pub mod constants;
pub mod cryptography;
pub mod dbm;
pub mod errors;
pub mod net;
pub mod receipts;
pub mod ser;
pub mod test_utils;
use std::fmt;
use std::{convert::TryFrom, str::FromStr};
use serde::{Deserialize, Serialize};
use serde_json::json;
use bitcoin::secp256k1::{Error, PublicKey};
pub const USER_ID_LEN: usize = 33;
pub use UserId as TowerId;
/// User identifier. A wrapper around a [PublicKey].
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Serialize, Deserialize)]
pub struct UserId(pub PublicKey);
impl UserId {
/// Encodes the user id in its byte representation.
pub fn serialize(&self) -> Vec<u8> {
pub fn to_vec(&self) -> Vec<u8> {
self.0.serialize().to_vec()
}
/// Builds a user id from its byte representation.
pub fn deserialize(data: &[u8]) -> Result<Self, Error> {
pub fn from_slice(data: &[u8]) -> Result<Self, Error> {
Ok(UserId(PublicKey::from_slice(data)?))
}
}
@ -47,3 +62,126 @@ impl fmt::Display for UserId {
write!(f, "{}", self.0)
}
}
impl TryFrom<serde_json::Value> for UserId {
type Error = String;
fn try_from(value: serde_json::Value) -> Result<Self, Self::Error> {
match value {
serde_json::Value::String(s) => UserId::from_str(&s),
serde_json::Value::Array(mut a) => {
let param_count = a.len();
if param_count == 1 {
UserId::try_from(a.pop().unwrap())
} else {
Err(format!(
"Unexpected json format. Expected a single parameter. Received: {param_count}"
))
}
}
serde_json::Value::Object(mut m) => {
let param_count = m.len();
if param_count > 1 {
Err(format!(
"Unexpected json format. Expected a single parameter. Received: {param_count}"
))
} else {
UserId::try_from(json!(m
.remove("user_id")
.or_else(|| m.remove("tower_id"))
.ok_or("user_id or tower_id not found")?))
}
}
_ => Err(format!(
"Unexpected request format. Expected: user_id/tower_id. Received: '{value}'"
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::collections::HashMap;
use crate::test_utils::get_random_user_id;
#[test]
fn try_from_json_string() {
let user_id = get_random_user_id();
assert_eq!(UserId::try_from(json!(user_id.to_string())), Ok(user_id));
}
#[test]
fn try_from_json_wrong_string() {
let user_id = "not_a_user_id";
assert!(matches!(
UserId::try_from(json!(user_id.to_string())),
Err(..)
));
}
#[test]
fn try_from_json_array() {
let user_id = get_random_user_id();
assert_eq!(UserId::try_from(json!([user_id.to_string()])), Ok(user_id));
}
#[test]
fn try_from_json_array_empty() {
assert!(matches!(UserId::try_from(json!([])), Err(..)));
}
#[test]
fn try_from_json_array_too_many_elements() {
let user_id = get_random_user_id();
assert!(matches!(
UserId::try_from(json!([user_id.to_string(), user_id.to_string()])),
Err(..)
));
}
#[test]
fn try_from_json_dict() {
let user_id = get_random_user_id();
assert_eq!(
UserId::try_from(json!(HashMap::from([("tower_id", user_id.to_string())]))),
Ok(user_id)
);
assert_eq!(
UserId::try_from(json!(HashMap::from([("user_id", user_id.to_string())]))),
Ok(user_id)
);
}
#[test]
fn try_from_json_empty_dict() {
assert!(matches!(
UserId::try_from(json!(HashMap::<String, serde_json::Value>::new())),
Err(..)
));
}
#[test]
fn try_from_json_wrong_dict() {
let user_id = get_random_user_id();
assert!(matches!(
UserId::try_from(json!(HashMap::from([("random_key", user_id.to_string())]))),
Err(..)
));
}
#[test]
fn try_from_json_dict_too_many_keys() {
let user_id = get_random_user_id();
assert!(matches!(
UserId::try_from(json!(HashMap::from([
("tower_id", user_id.to_string()),
("user_id", user_id.to_string())
]))),
Err(..)
));
}
}

View file

@ -0,0 +1,29 @@
pub enum Endpoint {
Register,
AddAppointment,
GetAppointment,
GetSubscriptionInfo,
Ping,
}
impl std::fmt::Display for Endpoint {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(
f,
"{}",
match self {
Endpoint::Register => "register",
Endpoint::AddAppointment => "add_appointment",
Endpoint::GetAppointment => "get_appointment",
Endpoint::GetSubscriptionInfo => "get_subscription_info",
Endpoint::Ping => "ping",
}
)
}
}
impl Endpoint {
pub fn path(&self) -> String {
format!("/{self}")
}
}

116
teos-common/src/net/mod.rs Normal file
View file

@ -0,0 +1,116 @@
pub mod http;
use serde::Serialize;
use std::fmt;
/// Represents all types of teos network addresses
#[derive(Clone, Serialize, Debug, PartialEq, Eq)]
pub enum AddressType {
IpV4 = 0,
TorV3 = 1,
}
impl From<i32> for AddressType {
fn from(x: i32) -> Self {
match x {
0 => AddressType::IpV4,
1 => AddressType::TorV3,
x => panic!("Unknown address type {}", x),
}
}
}
impl std::str::FromStr for AddressType {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"ipv4" => Ok(AddressType::IpV4),
"torv3" => Ok(AddressType::TorV3),
_ => Err(format!("Unknown type: {s}")),
}
}
}
impl fmt::Display for AddressType {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let s = match self {
AddressType::IpV4 => "ipv4",
AddressType::TorV3 => "torv3",
};
write!(f, "{s}")
}
}
impl AddressType {
pub fn get_type(net_addr: &str) -> AddressType {
if net_addr.contains(".onion:") {
AddressType::TorV3
} else {
AddressType::IpV4
}
}
pub fn is_tor(&self) -> bool {
self == &AddressType::TorV3
}
pub fn is_clearnet(&self) -> bool {
self == &AddressType::IpV4
}
}
#[derive(Clone, Serialize, Debug, PartialEq, Eq)]
pub struct NetAddr {
net_addr: String,
#[serde(skip)]
addr_type: AddressType,
}
impl NetAddr {
pub fn new(net_addr: String) -> Self {
NetAddr {
addr_type: AddressType::get_type(&net_addr),
net_addr,
}
}
pub fn net_addr(&self) -> &str {
&self.net_addr
}
pub fn addr_type(&self) -> &AddressType {
&self.addr_type
}
pub fn is_onion(&self) -> bool {
self.addr_type().is_tor()
}
}
#[cfg(test)]
pub mod tests {
use super::*;
pub const TORV3_ADDR: &str =
"recnedb7xfhzjdrcgxongzli3a6qyrv5jwgowoho3v5g3rwk7kkglrid.onion:9814";
pub const IPV4_ADDR: &str = "teos.talaia.watch:9814";
#[test]
fn test_get_type() {
assert_eq!(AddressType::get_type(TORV3_ADDR), AddressType::TorV3);
assert_eq!(AddressType::get_type(IPV4_ADDR), AddressType::IpV4);
}
#[test]
fn test_is_tor() {
assert!(NetAddr::new(TORV3_ADDR.to_owned()).addr_type.is_tor());
assert!(!NetAddr::new(IPV4_ADDR.to_owned()).addr_type.is_tor());
}
#[test]
fn test_is_clearnet() {
assert!(!NetAddr::new(TORV3_ADDR.to_owned()).addr_type.is_clearnet());
assert!(NetAddr::new(IPV4_ADDR.to_owned()).addr_type.is_clearnet());
}
}

View file

@ -1,27 +1,66 @@
//! Receipts issued by towers and handed to users as commitment proof.
use serde::Serialize;
use bitcoin::secp256k1::SecretKey;
use crate::{cryptography, UserId};
#[derive(Debug)]
/// Proof that a user has registered with a tower. This serves two purposes:
///
/// - First, the user is able to prove that the tower agreed on providing a service. If a tower refuses to accept appointments
/// from a user (claiming the subscription has expired) but the expiry time has still not passed and the tower cannot
/// provide the relevant appointments signed by the user, it means it is cheating.
/// - Second, it serves as proof, alongside an appointment receipt, that an appointment was not fulfilled. A registration receipt
/// specifies a subscription period (`subscription_start` - `subscription_expiry`) and the appointment a `start_block` so inclusion
/// can be proved.
///
/// TODO: / DISCUSS: In order to minimize the amount of receipts the user has to store, the tower could batch subscription receipts
/// as long as the user info is still known. That is, if a user has a subscription with range (S, E) and the user renews the subscription
/// before the tower wipes their data, then the tower can create a new receipt with (S, E') for E' > E instead of a second receipt (E, E').
// Notice this only applies as long as there is no gap between the two subscriptions.
#[derive(Serialize, Debug, Eq, PartialEq, Clone)]
pub struct RegistrationReceipt {
user_id: UserId,
available_slots: u32,
subscription_start: u32,
subscription_expiry: u32,
#[serde(rename = "subscription_signature")]
signature: Option<String>,
}
impl RegistrationReceipt {
pub fn new(user_id: UserId, available_slots: u32, subscription_expiry: u32) -> Self {
pub fn new(
user_id: UserId,
available_slots: u32,
subscription_start: u32,
subscription_expiry: u32,
) -> Self {
RegistrationReceipt {
user_id,
available_slots,
subscription_start,
subscription_expiry,
signature: None,
}
}
pub fn with_signature(
user_id: UserId,
available_slots: u32,
subscription_start: u32,
subscription_expiry: u32,
signature: String,
) -> Self {
RegistrationReceipt {
user_id,
available_slots,
subscription_start,
subscription_expiry,
signature: Some(signature),
}
}
pub fn user_id(&self) -> UserId {
self.user_id
}
@ -30,6 +69,10 @@ impl RegistrationReceipt {
self.available_slots
}
pub fn subscription_start(&self) -> u32 {
self.subscription_start
}
pub fn subscription_expiry(&self) -> u32 {
self.subscription_expiry
}
@ -38,21 +81,33 @@ impl RegistrationReceipt {
self.signature.clone()
}
pub fn serialize(&self) -> Vec<u8> {
pub fn to_vec(&self) -> Vec<u8> {
let mut ser = Vec::new();
ser.extend_from_slice(&self.user_id.serialize());
ser.extend_from_slice(&self.user_id.to_vec());
ser.extend_from_slice(&self.available_slots.to_be_bytes());
ser.extend_from_slice(&self.subscription_start.to_be_bytes());
ser.extend_from_slice(&self.subscription_expiry.to_be_bytes());
ser
}
pub fn sign(&mut self, sk: &SecretKey) {
// TODO: Check if there's any case where this can actually fail. Don't unwrap if so.
self.signature = Some(cryptography::sign(&self.serialize(), sk).unwrap());
self.signature = Some(cryptography::sign(&self.to_vec(), sk));
}
pub fn verify(&self, id: &UserId) -> bool {
if let Some(signature) = self.signature() {
cryptography::verify(&self.to_vec(), &signature, &id.0)
} else {
false
}
}
}
#[derive(Debug)]
/// Proof that a certain state was backed up with the tower.
///
/// Appointment receipts can be used alongside a registration receipt that covers it, and on chain data (a breach not being reacted with a penalty), to prove a tower has not reacted to a channel breach.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AppointmentReceipt {
user_signature: String,
start_block: u32,
@ -68,6 +123,14 @@ impl AppointmentReceipt {
}
}
pub fn with_signature(user_signature: String, start_block: u32, signature: String) -> Self {
AppointmentReceipt {
user_signature,
start_block,
signature: Some(signature),
}
}
pub fn user_signature(&self) -> &str {
&self.user_signature
}
@ -80,7 +143,7 @@ impl AppointmentReceipt {
self.signature.clone()
}
pub fn serialize(&self) -> Vec<u8> {
pub fn to_vec(&self) -> Vec<u8> {
let mut ser = Vec::new();
ser.extend_from_slice(self.user_signature.as_bytes());
ser.extend_from_slice(&self.start_block.to_be_bytes());
@ -89,7 +152,14 @@ impl AppointmentReceipt {
}
pub fn sign(&mut self, sk: &SecretKey) {
// TODO: Check if there's any case where this can actually fail. Don't unwrap if so.
self.signature = Some(cryptography::sign(&self.serialize(), sk).unwrap());
self.signature = Some(cryptography::sign(&self.to_vec(), sk));
}
pub fn verify(&self, id: &UserId) -> bool {
if let Some(signature) = self.signature() {
cryptography::verify(&self.to_vec(), &signature, &id.0)
} else {
false
}
}
}

146
teos-common/src/ser.rs Normal file
View file

@ -0,0 +1,146 @@
use std::collections::HashSet;
use serde::{ser::SerializeSeq, Serializer};
use crate::appointment::Locator;
pub fn serialize_locators<S>(hs: &HashSet<Locator>, s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut seq = s.serialize_seq(Some(hs.len()))?;
for element in hs.iter() {
seq.serialize_element(&hex::encode(element))?;
}
seq.end()
}
pub mod serde_be {
use super::*;
use serde::de::{self, Deserializer};
pub fn serialize<S>(v: &[u8], s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut v = v.to_owned();
v.reverse();
hex::serialize(v, s)
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
where
D: Deserializer<'de>,
{
struct BEVisitor;
impl<'de> de::Visitor<'de> for BEVisitor {
type Value = Vec<u8>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a hex encoded string")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
let mut v =
hex::decode(v).map_err(|_| E::custom("cannot deserialize the given value"))?;
v.reverse();
Ok(v)
}
}
deserializer.deserialize_any(BEVisitor)
}
}
pub mod serde_vec_bytes {
use super::*;
use serde::de::{self, Deserializer, SeqAccess};
pub fn serialize<S>(v: &[Vec<u8>], s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut seq = s.serialize_seq(Some(v.len()))?;
for element in v.iter() {
seq.serialize_element(&hex::encode(element))?;
}
seq.end()
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<Vec<u8>>, D::Error>
where
D: Deserializer<'de>,
{
struct VecVisitor;
impl<'de> de::Visitor<'de> for VecVisitor {
type Value = Vec<Vec<u8>>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a hex encoded string")
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: SeqAccess<'de>,
{
let mut result = Vec::new();
while let Some(v) = seq.next_element::<String>()? {
result
.push(hex::decode(v).map_err(|_| {
de::Error::custom("cannot deserialize the given value")
})?);
}
Ok(result)
}
}
deserializer.deserialize_any(VecVisitor)
}
}
pub mod serde_status {
use super::*;
use serde::de::{self, Deserializer};
use std::str::FromStr;
use crate::appointment::AppointmentStatus;
pub fn serialize<S>(status: &i32, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&AppointmentStatus::from(*status).to_string())
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<i32, D::Error>
where
D: Deserializer<'de>,
{
struct StatusVisitor;
impl<'de> de::Visitor<'de> for StatusVisitor {
type Value = i32;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a string containing the status")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
let status = AppointmentStatus::from_str(v)
.map_err(|_| E::custom("given status is unknown"))?;
Ok(status as i32)
}
}
deserializer.deserialize_any(StatusVisitor)
}
}

View file

@ -0,0 +1,100 @@
use std::convert::TryInto;
use bitcoin::script::PushBytesBuf;
use hex::FromHex;
use rand::distributions::Standard;
use rand::prelude::Distribution;
use rand::Rng;
use bitcoin::hashes::Hash;
use bitcoin::secp256k1::SecretKey;
use bitcoin::{consensus, Amount, ScriptBuf, Transaction, TxOut, Txid};
use crate::appointment::{Appointment, Locator};
use crate::cryptography;
use crate::receipts::{AppointmentReceipt, RegistrationReceipt};
use crate::UserId;
pub static TXID_HEX: &str = "338bda693c4a26e0d41a01f7f2887aaf48bf0bdf93e6415c9110b29349349d3e";
pub static TX_HEX: &str = "010000000001010000000000000000000000000000000000000000000000000000000000000000ffffffff54038e830a1b4d696e656420627920416e74506f6f6c373432c2005b005e7a0ae3fabe6d6d7841cd582ead8ea5dd8e3de1173cae6fcd2a53c7362ebb7fb6f815604fe07cbe0200000000000000ac0e060005f90000ffffffff04d9476026000000001976a91411dbe48cc6b617f9c6adaf4d9ed5f625b1c7cb5988ac0000000000000000266a24aa21a9ed7248c6efddd8d99bfddd7f499f0b915bffa8253003cc934df1ff14a81301e2340000000000000000266a24b9e11b6d7054937e13f39529d6ad7e685e9dd4efa426f247d5f5a5bed58cdddb2d0fa60100000000000000002b6a2952534b424c4f434b3a054a68aa5368740e8b3e3c67bce45619c2cfd07d4d4f0936a5612d2d0034fa0a0120000000000000000000000000000000000000000000000000000000000000000000000000";
pub fn get_random_int<T>() -> T
where
Standard: Distribution<T>,
{
let mut rng = rand::thread_rng();
rng.gen()
}
pub fn get_random_user_id() -> UserId {
let (_, pk) = cryptography::get_random_keypair();
UserId(pk)
}
pub fn get_random_locator() -> Locator {
let mut rng = rand::thread_rng();
Locator::from_slice(&rng.gen::<[u8; 16]>()).unwrap()
}
pub fn generate_random_appointment(dispute_txid: Option<&Txid>) -> Appointment {
let dispute_txid = match dispute_txid {
Some(l) => *l,
None => {
let prev_txid_bytes = cryptography::get_random_bytes(32);
Txid::from_slice(&prev_txid_bytes).unwrap()
}
};
let tx_bytes = Vec::from_hex(TX_HEX).unwrap();
let mut penalty_tx: Transaction = consensus::deserialize(&tx_bytes).unwrap();
let size = get_random_int::<usize>() % 81;
let mut push_bytes_buf = PushBytesBuf::new();
PushBytesBuf::extend_from_slice(&mut push_bytes_buf, &cryptography::get_random_bytes(size))
.unwrap();
let script_pubkey = ScriptBuf::new_op_return(push_bytes_buf);
// Append a random-sized OP_RETURN to make each transcation random in size.
penalty_tx.output.push(TxOut {
value: Amount::from_sat(0),
script_pubkey,
});
let mut raw_locator: [u8; 16] = cryptography::get_random_bytes(16).try_into().unwrap();
raw_locator.copy_from_slice(&dispute_txid[..16]);
let locator = Locator::from_slice(&raw_locator).unwrap();
let encrypted_blob = cryptography::encrypt(&penalty_tx, &dispute_txid).unwrap();
Appointment::new(locator, encrypted_blob, get_random_int())
}
pub fn get_random_registration_receipt() -> RegistrationReceipt {
let (sk, _) = cryptography::get_random_keypair();
let start = get_random_int();
let mut receipt =
RegistrationReceipt::new(get_random_user_id(), get_random_int(), start, start + 420);
receipt.sign(&sk);
receipt
}
pub fn get_registration_receipt_from_previous(r: &RegistrationReceipt) -> RegistrationReceipt {
let (sk, _) = cryptography::get_random_keypair();
let mut receipt = RegistrationReceipt::new(
r.user_id(),
r.available_slots() + 1 + get_random_int::<u8>() as u32,
r.subscription_start(),
r.subscription_expiry() + 1 + get_random_int::<u8>() as u32,
);
receipt.sign(&sk);
receipt
}
pub fn get_random_appointment_receipt(tower_sk: SecretKey) -> AppointmentReceipt {
let mut receipt = AppointmentReceipt::new("user_sig".into(), 42);
receipt.sign(&tower_sk);
receipt
}

View file

@ -1,9 +1,9 @@
[package]
name = "teos"
version = "0.0.1"
version = "0.2.0"
authors = ["Sergi Delgado Segura <sergi.delgado.s@gmail.com>"]
license = "MIT"
edition = "2018"
edition = "2021"
default-run="teosd"
[[bin]]
@ -19,33 +19,36 @@ path = "src/main.rs"
hex = { version = "0.4.3", features = [ "serde" ] }
home = "0.5.3"
log = "0.4"
prost = "0.9"
prost = "0.12"
rcgen = { version = "0.13.1", features = ["pem", "x509-parser"] }
rusqlite = { version = "0.26.0", features = [ "bundled", "limits" ] }
serde = "1.0.130"
serde_json = "1.0"
simple_logger = "2.1.0"
structopt = "0.3"
toml = "0.5"
tonic = "0.6"
tonic = { version = "0.11", features = [ "tls", "transport" ] }
tokio = { version = "1.5", features = [ "rt-multi-thread" ] }
triggered = "0.1.2"
warp = "0.3.2"
warp = "0.3.5"
torut = "0.2.1"
base64 = "0.22.1"
# Bitcoin and Lightning
bitcoin = { version = "0.27", features = [ "base64" ] }
bitcoincore-rpc = "0.14.0"
lightning = "0.0.104"
lightning-net-tokio = "0.0.104"
lightning-block-sync = { version = "0.0.104", features = [ "rpc-client" ] }
bitcoin = { version = "0.32.0" }
bitcoincore-rpc = "0.19.0"
lightning = "0.1.0"
lightning-net-tokio = "0.1.0"
lightning-block-sync = { version = "0.1.0", features = [ "rpc-client" ] }
# Local
teos-common = { path = "../teos-common" }
[build-dependencies]
tonic-build = "0.6"
tonic-build = "0.11"
[dev-dependencies]
chunked_transfer = "1.4"
rand = "0.8.4"
jsonrpc-http-server = "17.1.0"
tokio-stream = { version = "0.1.5", features = [ "net" ] }
rand = "0.8.4"
tempdir = "0.3.7"
tokio-stream = { version = "0.1.5", features = [ "net" ] }

View file

@ -1,37 +1,28 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_build::configure()
.extern_path(".common.teos.v2", "::teos-common::protos")
.type_attribute(".", "#[derive(serde::Serialize, serde::Deserialize)]")
.type_attribute("AppointmentData.appointment_data", "#[serde(untagged)]")
.field_attribute("AppointmentData.appointment_data", "#[serde(flatten)]")
.field_attribute("appointment_data", "#[serde(rename = \"appointment\")]")
.field_attribute("user_id", "#[serde(with = \"hex::serde\")]")
.field_attribute("tower_id", "#[serde(with = \"hex::serde\")]")
.field_attribute("locator", "#[serde(with = \"hex::serde\")]")
.field_attribute("encrypted_blob", "#[serde(with = \"hex::serde\")]")
.field_attribute("tx", "#[serde(with = \"hex::serde\")]")
.field_attribute(
"locators",
"#[serde(serialize_with = \"crate::api::http::serialize_vec_bytes\")]",
)
.field_attribute(
"user_ids",
"#[serde(serialize_with = \"crate::api::http::serialize_vec_bytes\")]",
"#[serde(serialize_with = \"teos_common::ser::serde_vec_bytes::serialize\")]",
)
.field_attribute(
"GetUserResponse.appointments",
"#[serde(serialize_with = \"crate::api::http::serialize_vec_bytes\")]",
"#[serde(serialize_with = \"teos_common::ser::serde_vec_bytes::serialize\")]",
)
.field_attribute(
"GetAppointmentResponse.status",
"#[serde(with = \"crate::api::serde_status\")]",
"NetworkAddress.address_type",
"#[serde(rename = \"type\", with = \"crate::api::serde::serde_address_type\")]",
)
.compile(
&[
"proto/teos/appointment.proto",
"proto/teos/tower_services.proto",
"proto/teos/user.proto",
"proto/teos/v2/appointment.proto",
"proto/teos/v2/tower_services.proto",
"proto/teos/v2/user.proto",
],
&["proto/teos"],
&["proto/teos/v2", "../teos-common/proto/"],
)?;
Ok(())

View file

@ -1,81 +0,0 @@
syntax = "proto3";
package teos.v2;
message Appointment {
/*
Contains the basic information about an appointment (Watcher) and it's used for messages like
AddAppointmentRequest or encapsulated inside AppointmentData for GetAppointmentResponse
*/
bytes locator = 1;
bytes encrypted_blob = 2;
uint32 to_self_delay = 3;
}
message Tracker {
// It's the equivalent of an appointment message from data held by the Responder.
bytes dispute_txid = 1;
bytes penalty_txid = 2;
bytes penalty_rawtx = 3;
}
message AppointmentData {
/*
Encapsulates the data for a GetAppointmentResponse, given it can be an appointment (data is on the Watcher) or a
tracker (data is on the Responder).
*/
oneof appointment_data {
Appointment appointment = 1;
Tracker tracker = 2;
}
}
message AddAppointmentRequest {
// Request to add an appointment to the backend, contains the appointment data and the user signature.
Appointment appointment = 1;
string signature = 2;
}
message AddAppointmentResponse {
/*
Response to an AddAppointmentRequest, contains the locator to identify the added appointment, the tower signature,
the block at which the tower has started (or will start) watching for the appointment, and the updated subscription
information.
*/
bytes locator = 1;
uint32 start_block = 2;
string signature = 3;
uint32 available_slots = 4;
uint32 subscription_expiry = 5;
}
message GetAppointmentRequest {
// Request to get information about an appointment. Contains the appointment locator and a signature by the user.
bytes locator = 1;
string signature = 2;
}
message GetAppointmentResponse {
// Response to a GetAppointmentRequest. Contains the appointment data encapsulated in an AppointmentData message.
AppointmentData appointment_data = 1;
enum AppointmentStatus {
NOT_FOUND = 0;
BEING_WATCHED = 1;
DISPUTE_RESPONDED = 2;
}
AppointmentStatus status = 2;
}
message GetAllAppointmentsResponse {
// Response with data about all the appointments in the tower.
repeated AppointmentData appointments = 1;
}

View file

@ -1,51 +0,0 @@
syntax = "proto3";
package teos.v2;
message RegisterRequest {
// Requests a user registration with the tower. Contains the user id in the form of a compressed ECDSA public key.
bytes user_id = 1;
}
message RegisterResponse {
// Response to a RegisterRequest, contains the registration information alongside the tower signature of the agreement.
bytes user_id = 1;
uint32 available_slots = 2;
uint32 subscription_expiry = 3;
string subscription_signature = 4;
}
message GetUserRequest {
// Request to get information about a specific user. Contains the user id.
bytes user_id = 1;
}
message GetUserResponse {
// Response with the information the tower has about a specific user
uint32 available_slots = 1;
uint32 subscription_expiry = 2;
repeated bytes appointments = 3;
}
message GetUsersResponse {
// Response with information about all the users registered with the tower. Contains a list of user ids.
repeated bytes user_ids = 1;
}
message GetSubscriptionInfoRequest {
// Request to get a specific user's subscription info.
string signature = 1;
}
message GetSubscriptionInfoResponse {
// Response with the information the tower has about a specific user
uint32 available_slots = 1;
uint32 subscription_expiry = 2;
repeated bytes locators = 3;
}

View file

@ -0,0 +1,22 @@
syntax = "proto3";
package teos.v2;
import "common/teos/v2/appointment.proto";
message GetAppointmentsRequest {
// Request the information of appointments with specific locator.
bytes locator = 1;
}
message GetAppointmentsResponse {
// Response with the information of all appointments with a specific locator.
repeated common.teos.v2.AppointmentData appointments = 1;
}
message GetAllAppointmentsResponse {
// Response with data about all the appointments in the tower.
repeated common.teos.v2.AppointmentData appointments = 1;
}

View file

@ -3,31 +3,46 @@ package teos.v2;
import "appointment.proto";
import "user.proto";
import "common/teos/v2/appointment.proto";
import "common/teos/v2/user.proto";
import "google/protobuf/empty.proto";
message NetworkAddress {
// Tower public API endpoint.
enum AddressType {
IpV4 = 0;
TorV3 = 1;
}
AddressType address_type = 1;
string address = 2;
uint32 port = 3;
}
message GetTowerInfoResponse {
// Response with information about the tower.
bytes tower_id = 1;
uint32 n_registered_users = 2;
uint32 n_watcher_appointments = 3;
uint32 n_responder_trackers = 4;
bool bitcoind_reachable = 5;
repeated NetworkAddress addresses = 6;
}
service PublicTowerServices {
// Public tower services, only reachable from the public API.
rpc register(RegisterRequest) returns (RegisterResponse) {}
rpc add_appointment(AddAppointmentRequest) returns (AddAppointmentResponse) {}
rpc get_appointment(GetAppointmentRequest) returns (GetAppointmentResponse) {}
rpc get_subscription_info(GetSubscriptionInfoRequest) returns (GetSubscriptionInfoResponse) {}
rpc register(common.teos.v2.RegisterRequest) returns (common.teos.v2.RegisterResponse) {}
rpc add_appointment(common.teos.v2.AddAppointmentRequest) returns (common.teos.v2.AddAppointmentResponse) {}
rpc get_appointment(common.teos.v2.GetAppointmentRequest) returns (common.teos.v2.GetAppointmentResponse) {}
rpc get_subscription_info(common.teos.v2.GetSubscriptionInfoRequest) returns (common.teos.v2.GetSubscriptionInfoResponse) {}
}
service PrivateTowerServices {
// Private tower services, only reachable from the private API.
rpc get_all_appointments(google.protobuf.Empty) returns (GetAllAppointmentsResponse) {}
rpc get_appointments(GetAppointmentsRequest) returns (GetAppointmentsResponse) {}
rpc get_tower_info(google.protobuf.Empty) returns (GetTowerInfoResponse) {}
rpc get_users(google.protobuf.Empty) returns (GetUsersResponse) {}
rpc get_user(GetUserRequest) returns (GetUserResponse) {}

View file

@ -0,0 +1,22 @@
syntax = "proto3";
package teos.v2;
message GetUserRequest {
// Request to get information about a specific user. Contains the user id.
bytes user_id = 1;
}
message GetUserResponse {
// Response with the information the tower has about a specific user
uint32 available_slots = 1;
uint32 subscription_expiry = 2;
repeated bytes appointments = 3;
}
message GetUsersResponse {
// Response with information about all the users registered with the tower. Contains a list of user ids.
repeated bytes user_ids = 1;
}

View file

@ -1,15 +1,17 @@
use serde::{ser::SerializeSeq, Deserialize, Serialize, Serializer};
use serde::{Deserialize, Serialize};
use std::convert::Infallible;
use std::error::Error;
use std::net::SocketAddr;
use tokio::time::Duration;
use tonic::transport::Channel;
use triggered::Listener;
use triggered::{Listener, Trigger};
use warp::{http::StatusCode, reject, reply, Filter, Rejection, Reply};
use teos_common::appointment::LOCATOR_LEN;
use teos_common::net::http::Endpoint;
use teos_common::protos as common_msgs;
use teos_common::{errors, USER_ID_LEN};
use crate::protos as msgs;
use crate::protos::public_tower_services_client::PublicTowerServicesClient;
// TODO: Limit the body length for /add_appointment should not be needed, since slots are consumed proportionally to it.
@ -34,14 +36,14 @@ impl ApiError {
fn missing_field(field_name: &str) -> Rejection {
reject::custom(Self::new(
format!("missing field `{}`", field_name),
format!("missing field `{field_name}`"),
errors::MISSING_FIELD,
))
}
fn empty_field(field_name: &str) -> Rejection {
reject::custom(Self::new(
format!("`{}` field is empty", field_name),
format!("`{field_name}` field is empty"),
errors::EMPTY_FIELD,
))
}
@ -49,25 +51,13 @@ impl ApiError {
fn wrong_field_length(field_name: &str, field_size: usize, expected_size: usize) -> Rejection {
reject::custom(Self::new(
format!(
"Wrong `{}` field size. Expected {}, received {}",
field_name, expected_size, field_size
"Wrong `{field_name}` field size. Expected {expected_size}, received {field_size}"
),
errors::WRONG_FIELD_SIZE,
))
}
}
pub fn serialize_vec_bytes<S>(v: &[Vec<u8>], s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut seq = s.serialize_seq(Some(v.len()))?;
for element in v.iter() {
seq.serialize_element(&hex::encode(element))?;
}
seq.end()
}
fn with_grpc(
grpc_endpoint: PublicTowerServicesClient<Channel>,
) -> impl Filter<Extract = (PublicTowerServicesClient<Channel>,), Error = Infallible> + Clone {
@ -107,13 +97,13 @@ fn parse_grpc_response<T: serde::Serialize>(
match result {
Ok(r) => {
let inner = r.into_inner();
log::info!("Request succeeded");
log::debug!("Request succeeded");
log::debug!("Response: {}", serde_json::json!(inner));
(reply::json(&inner), StatusCode::OK)
}
Err(s) => {
let (status_code, error_code) = match_status(&s);
log::info!("Request failed, error_code={}", error_code);
log::debug!("Request failed, error_code={error_code}");
log::debug!("Response: {}", serde_json::json!(s.message()));
(
reply::json(&ApiError::new(s.message().into(), error_code)),
@ -124,14 +114,14 @@ fn parse_grpc_response<T: serde::Serialize>(
}
async fn register(
req: msgs::RegisterRequest,
req: common_msgs::RegisterRequest,
addr: Option<std::net::SocketAddr>,
mut grpc_conn: PublicTowerServicesClient<Channel>,
) -> std::result::Result<impl Reply, Rejection> {
match addr {
Some(a) => log::info!("Received register request from {}", a),
None => log::info!("Received register request from unknown address"),
}
log::debug!(
"Received a register request from {}",
addr.map_or("an unknown address".to_owned(), |a| a.to_string())
);
let user_id = req.user_id.clone();
if user_id.is_empty() {
@ -150,14 +140,14 @@ async fn register(
}
async fn add_appointment(
req: msgs::AddAppointmentRequest,
req: common_msgs::AddAppointmentRequest,
addr: Option<std::net::SocketAddr>,
mut grpc_conn: PublicTowerServicesClient<Channel>,
) -> std::result::Result<impl Reply, Rejection> {
match addr {
Some(a) => log::info!("Received add_appointment request from {}", a),
None => log::info!("Received add_appointment request from unknown address"),
}
log::debug!(
"Received an add_appointment request from {}",
addr.map_or("an unknown address".to_owned(), |a| a.to_string())
);
if let Some(a) = &req.appointment {
if a.locator.is_empty() {
@ -182,14 +172,14 @@ async fn add_appointment(
}
async fn get_appointment(
req: msgs::GetAppointmentRequest,
req: common_msgs::GetAppointmentRequest,
addr: Option<std::net::SocketAddr>,
mut grpc_conn: PublicTowerServicesClient<Channel>,
) -> std::result::Result<impl Reply, Rejection> {
match addr {
Some(a) => log::info!("Received get_appointment request from {}", a),
None => log::info!("Received get_appointment request from unknown address"),
}
log::debug!(
"Received an get_appointment request from {}",
addr.map_or("an unknown address".to_owned(), |a| a.to_string())
);
if req.locator.is_empty() {
return Err(ApiError::empty_field("locator"));
@ -210,14 +200,14 @@ async fn get_appointment(
}
async fn get_subscription_info(
req: msgs::GetSubscriptionInfoRequest,
req: common_msgs::GetSubscriptionInfoRequest,
addr: Option<std::net::SocketAddr>,
mut grpc_conn: PublicTowerServicesClient<Channel>,
) -> std::result::Result<impl Reply, Rejection> {
match addr {
Some(a) => log::info!("Received get_subscription_info request from {}", a),
None => log::info!("Received get_subscription_info request from unknown address"),
}
log::debug!(
"Received an get_subscription_info request from {}",
addr.map_or("an unknown address".to_owned(), |a| a.to_string())
);
if req.signature.is_empty() {
return Err(ApiError::empty_field("signature"));
@ -227,32 +217,40 @@ async fn get_subscription_info(
Ok(reply::with_status(body, status))
}
async fn ping(addr: Option<SocketAddr>) -> Result<impl Reply, Rejection> {
log::debug!(
"Received a ping request from {}",
addr.map_or("an unknown address".to_owned(), |a| a.to_string())
);
Ok(reply::reply())
}
fn router(
grpc_conn: PublicTowerServicesClient<Channel>,
) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone {
) -> impl Filter<Extract = (impl Reply,), Error = Rejection> + Clone {
let register = warp::post()
.and(warp::path("register"))
.and(warp::path(Endpoint::Register.to_string()))
.and(warp::body::content_length_limit(REGISTER_BODY_LEN).and(warp::body::json()))
.and(warp::addr::remote())
.and(with_grpc(grpc_conn.clone()))
.and_then(register);
let add_appointment = warp::post()
.and(warp::path("add_appointment"))
.and(warp::path(Endpoint::AddAppointment.to_string()))
.and(warp::body::content_length_limit(ADD_APPOINTMENT_BODY_LEN).and(warp::body::json()))
.and(warp::addr::remote())
.and(with_grpc(grpc_conn.clone()))
.and_then(add_appointment);
let get_appointment = warp::post()
.and(warp::path("get_appointment"))
.and(warp::path(Endpoint::GetAppointment.to_string()))
.and(warp::body::content_length_limit(GET_APPOINTMENT_BODY_LEN).and(warp::body::json()))
.and(warp::addr::remote())
.and(with_grpc(grpc_conn.clone()))
.and_then(get_appointment);
let get_subscription_info = warp::post()
.and(warp::path("get_subscription_info"))
.and(warp::path(Endpoint::GetSubscriptionInfo.to_string()))
.and(
warp::body::content_length_limit(GET_SUBSCRIPTION_INFO_BODY_LEN)
.and(warp::body::json()),
@ -261,10 +259,16 @@ fn router(
.and(with_grpc(grpc_conn))
.and_then(get_subscription_info);
let ping = warp::get()
.and(warp::path(Endpoint::Ping.to_string()))
.and(warp::addr::remote())
.and_then(ping);
register
.or(add_appointment)
.or(get_appointment)
.or(get_subscription_info)
.or(ping)
.recover(handle_rejection)
}
@ -298,10 +302,24 @@ async fn handle_rejection(err: Rejection) -> Result<impl Reply, Rejection> {
}
}
pub async fn serve(http_bind: SocketAddr, grpc_bind: String, shutdown_signal: Listener) {
let grpc_conn = PublicTowerServicesClient::connect(grpc_bind).await.unwrap();
let (_, server) = warp::serve(router(grpc_conn))
.bind_with_graceful_shutdown(http_bind, async { shutdown_signal.await });
pub async fn serve(
http_bind: SocketAddr,
grpc_bind: SocketAddr,
service_ready: Trigger,
shutdown_signal: Listener,
) {
let grpc_conn = loop {
match PublicTowerServicesClient::connect(format!("http://{grpc_bind}")).await {
Ok(conn) => break conn,
Err(_) => {
log::error!("Cannot connect to the gRPC server. Retrying shortly");
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
};
let (_, server) =
warp::serve(router(grpc_conn)).bind_with_graceful_shutdown(http_bind, shutdown_signal);
service_ready.trigger();
server.await
}
@ -317,7 +335,7 @@ mod test_helpers {
use crate::api::internal::InternalAPI;
use crate::protos::public_tower_services_server::PublicTowerServicesServer;
use crate::test_utils::{create_api_with_config, ApiConfig};
use crate::test_utils::{create_api_with_config, ApiConfig, BitcoindStopper};
pub(crate) enum RequestBody<'a> {
Jsonify(&'a str),
@ -328,8 +346,8 @@ mod test_helpers {
pub(crate) async fn run_tower_in_background_with_config(
api_config: ApiConfig,
) -> (SocketAddr, Arc<InternalAPI>) {
let internal_rpc_api = create_api_with_config(api_config).await;
) -> (SocketAddr, Arc<InternalAPI>, BitcoindStopper) {
let (internal_rpc_api, bitcoind_stopper) = create_api_with_config(api_config).await;
let cloned = internal_rpc_api.clone();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
@ -343,18 +361,19 @@ mod test_helpers {
.unwrap();
});
(addr, cloned)
(addr, cloned, bitcoind_stopper)
}
pub(crate) async fn run_tower_in_background() -> SocketAddr {
run_tower_in_background_with_config(ApiConfig::default())
.await
.0
pub(crate) async fn run_tower_in_background() -> (SocketAddr, BitcoindStopper) {
let (sock_addr, _, bitcoind_stopper) =
run_tower_in_background_with_config(ApiConfig::default()).await;
(sock_addr, bitcoind_stopper)
}
pub(crate) async fn check_api_error<'a>(
endpoint: &str,
body: RequestBody<'a>,
pub(crate) async fn check_api_error(
endpoint: Endpoint,
body: RequestBody<'_>,
server_addr: SocketAddr,
) -> (ApiError, StatusCode) {
let grpc_conn = PublicTowerServicesClient::connect(format!(
@ -366,15 +385,22 @@ mod test_helpers {
.unwrap();
let req = match body {
RequestBody::Json(j) => warp::test::request().method("POST").path(endpoint).json(&j),
RequestBody::DoNotJsonify(j) => {
warp::test::request().method("POST").path(endpoint).json(&j)
}
RequestBody::Json(j) => warp::test::request()
.method("POST")
.path(&endpoint.path())
.json(&j),
RequestBody::DoNotJsonify(j) => warp::test::request()
.method("POST")
.path(&endpoint.path())
.json(&j),
RequestBody::Jsonify(j) => warp::test::request()
.method("POST")
.path(endpoint)
.path(&endpoint.path())
.json(&serde_json::from_str::<Value>(j).unwrap()),
RequestBody::Body(b) => warp::test::request().method("POST").path(endpoint).body(b),
RequestBody::Body(b) => warp::test::request()
.method("POST")
.path(&endpoint.path())
.body(b),
};
let res = req.reply(&router(grpc_conn)).await;
@ -385,7 +411,7 @@ mod test_helpers {
}
pub(crate) async fn request_to_api<B, T>(
endpoint: &str,
endpoint: Endpoint,
body: B,
server_addr: SocketAddr,
) -> Result<T, serde_json::Error>
@ -403,7 +429,7 @@ mod test_helpers {
let res = warp::test::request()
.method("POST")
.path(endpoint)
.path(&endpoint.path())
.json(&serde_json::json!(body))
.reply(&router(grpc_conn))
.await;
@ -414,16 +440,16 @@ mod test_helpers {
#[cfg(test)]
mod tests_failures {
use super::test_helpers::{check_api_error, run_tower_in_background, RequestBody};
use super::*;
use super::test_helpers::{check_api_error, run_tower_in_background, RequestBody};
use crate::test_utils::get_random_user_id;
use teos_common::test_utils::get_random_user_id;
#[tokio::test]
async fn test_no_json_request_body() {
let server_addr = run_tower_in_background().await;
let (server_addr, _s) = run_tower_in_background().await;
let (api_error, status) =
check_api_error("/register", RequestBody::Body(""), server_addr).await;
check_api_error(Endpoint::Register, RequestBody::Body(""), server_addr).await;
assert!(api_error.error.contains("EOF while parsing"));
assert_eq!(api_error.error_code, errors::INVALID_REQUEST_FORMAT);
assert_eq!(status, StatusCode::BAD_REQUEST);
@ -431,9 +457,13 @@ mod tests_failures {
#[tokio::test]
async fn test_wrong_json_request_body() {
let server_addr = run_tower_in_background().await;
let (api_error, status) =
check_api_error("/register", RequestBody::DoNotJsonify(""), server_addr).await;
let (server_addr, _s) = run_tower_in_background().await;
let (api_error, status) = check_api_error(
Endpoint::Register,
RequestBody::DoNotJsonify(""),
server_addr,
)
.await;
assert!(api_error.error.contains("expected struct"));
assert_eq!(api_error.error_code, errors::WRONG_FIELD_TYPE);
assert_eq!(status, StatusCode::BAD_REQUEST);
@ -441,9 +471,13 @@ mod tests_failures {
#[tokio::test]
async fn test_empty_json_request_body() {
let server_addr = run_tower_in_background().await;
let (api_error, status) =
check_api_error("/register", RequestBody::Jsonify(r#"{}"#), server_addr).await;
let (server_addr, _s) = run_tower_in_background().await;
let (api_error, status) = check_api_error(
Endpoint::Register,
RequestBody::Jsonify(r#"{}"#),
server_addr,
)
.await;
assert!(api_error.error.contains("missing field"));
assert_eq!(api_error.error_code, errors::MISSING_FIELD);
assert_eq!(status, StatusCode::BAD_REQUEST);
@ -451,9 +485,9 @@ mod tests_failures {
#[tokio::test]
async fn test_empty_field() {
let server_addr = run_tower_in_background().await;
let (server_addr, _s) = run_tower_in_background().await;
let (api_error, status) = check_api_error(
"/register",
Endpoint::Register,
RequestBody::Jsonify(r#"{"user_id": ""}"#),
server_addr,
)
@ -465,9 +499,9 @@ mod tests_failures {
#[tokio::test]
async fn test_wrong_field_hex_encoding_odd() {
let server_addr = run_tower_in_background().await;
let (server_addr, _s) = run_tower_in_background().await;
let (api_error, status) = check_api_error(
"/register",
Endpoint::Register,
RequestBody::Jsonify(r#"{"user_id": "a"}"#),
server_addr,
)
@ -479,9 +513,9 @@ mod tests_failures {
#[tokio::test]
async fn test_wrong_hex_encoding_character() {
let server_addr = run_tower_in_background().await;
let (server_addr, _s) = run_tower_in_background().await;
let (api_error, status) =
check_api_error("/register",
check_api_error(Endpoint::Register,
RequestBody::Jsonify(r#"{"user_id": "022fa2900ed7fc07b4e8ca3ea081e846245b0497944644aa78ea0b994ac22074dZ"}"#),
server_addr
).await;
@ -493,9 +527,9 @@ mod tests_failures {
#[tokio::test]
async fn test_wrong_field_size() {
let server_addr = run_tower_in_background().await;
let (server_addr, _s) = run_tower_in_background().await;
let (api_error, status) = check_api_error(
"/register",
Endpoint::Register,
RequestBody::Jsonify(r#"{"user_id": "aa"}"#),
server_addr,
)
@ -508,9 +542,9 @@ mod tests_failures {
#[tokio::test]
async fn test_wrong_field_type() {
let server_addr = run_tower_in_background().await;
let (server_addr, _s) = run_tower_in_background().await;
let (api_error, status) = check_api_error(
"/register",
Endpoint::Register,
RequestBody::DoNotJsonify(r#"{"user_id": 1}"#),
server_addr,
)
@ -523,9 +557,9 @@ mod tests_failures {
#[tokio::test]
async fn test_request_missing_field() {
// We'll use a different endpoint here since we need a json object with more than one field
let server_addr = run_tower_in_background().await;
let (server_addr, _s) = run_tower_in_background().await;
let (api_error, status) = check_api_error(
"/add_appointment",
Endpoint::AddAppointment,
RequestBody::Jsonify(r#"{"signature": "aa"}"#),
server_addr,
)
@ -539,7 +573,7 @@ mod tests_failures {
#[tokio::test]
async fn test_empty_request_body() {
let server_addr = run_tower_in_background().await;
let (server_addr, _s) = run_tower_in_background().await;
let grpc_conn = PublicTowerServicesClient::connect(format!(
"http://{}:{}",
server_addr.ip(),
@ -550,16 +584,16 @@ mod tests_failures {
let res = warp::test::request()
.method("POST")
.path("/register")
.path(&Endpoint::Register.path())
.reply(&router(grpc_conn))
.await;
assert_eq!(res.status(), StatusCode::LENGTH_REQUIRED)
assert_eq!(res.status(), StatusCode::LENGTH_REQUIRED);
}
#[tokio::test]
async fn test_payload_too_large() {
let server_addr = run_tower_in_background().await;
let (server_addr, _s) = run_tower_in_background().await;
let grpc_conn = PublicTowerServicesClient::connect(format!(
"http://{}:{}",
server_addr.ip(),
@ -570,17 +604,17 @@ mod tests_failures {
let res = warp::test::request()
.method("POST")
.path("/register")
.path(&Endpoint::Register.path())
.json(&format!("{}{}", get_random_user_id(), get_random_user_id()))
.reply(&router(grpc_conn))
.await;
assert_eq!(res.status(), StatusCode::PAYLOAD_TOO_LARGE)
assert_eq!(res.status(), StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test]
async fn test_wrong_endpoint() {
let server_addr = run_tower_in_background().await;
let (server_addr, _s) = run_tower_in_background().await;
let grpc_conn = PublicTowerServicesClient::connect(format!(
"http://{}:{}",
server_addr.ip(),
@ -591,17 +625,16 @@ mod tests_failures {
let res = warp::test::request()
.method("POST")
.path("/")
.json(&"")
.reply(&router(grpc_conn))
.await;
assert_eq!(res.status(), StatusCode::NOT_FOUND)
assert_eq!(res.status(), StatusCode::METHOD_NOT_ALLOWED);
}
#[tokio::test]
async fn test_wrong_method() {
let server_addr = run_tower_in_background().await;
let (server_addr, _s) = run_tower_in_background().await;
let grpc_conn = PublicTowerServicesClient::connect(format!(
"http://{}:{}",
server_addr.ip(),
@ -611,54 +644,57 @@ mod tests_failures {
.unwrap();
let res = warp::test::request()
.path("/")
.json(&"")
.reply(&router(grpc_conn))
.await;
assert_eq!(res.status(), StatusCode::METHOD_NOT_ALLOWED)
assert_eq!(res.status(), StatusCode::METHOD_NOT_ALLOWED);
}
}
#[cfg(test)]
mod tests_methods {
use super::*;
use super::test_helpers::{
check_api_error, request_to_api, run_tower_in_background,
run_tower_in_background_with_config, RequestBody,
};
use crate::extended_appointment::UUID;
use super::*;
use crate::responder::{ConfirmationStatus, TransactionTracker};
use crate::test_utils::{
generate_dummy_appointment, get_random_user_id, ApiConfig, DURATION, SLOTS,
generate_dummy_appointment, get_random_tx, ApiConfig, DURATION, SLOTS,
};
use crate::watcher::Breach;
use teos_common::test_utils::get_random_user_id;
use teos_common::{cryptography, UserId};
#[tokio::test]
async fn test_register() {
let server_addr = run_tower_in_background().await;
let response = request_to_api::<msgs::RegisterRequest, msgs::RegisterResponse>(
"/register",
msgs::RegisterRequest {
user_id: get_random_user_id().serialize(),
},
server_addr,
)
.await;
assert!(matches!(response, Ok(msgs::RegisterResponse { .. })));
let (server_addr, _s) = run_tower_in_background().await;
let response =
request_to_api::<common_msgs::RegisterRequest, common_msgs::RegisterResponse>(
Endpoint::Register,
common_msgs::RegisterRequest {
user_id: get_random_user_id().to_vec(),
},
server_addr,
)
.await;
assert!(matches!(response, Ok(common_msgs::RegisterResponse { .. })));
}
#[tokio::test]
async fn test_register_max_slots() {
let (server_addr, _) =
let (server_addr, _, _s) =
run_tower_in_background_with_config(ApiConfig::new(u32::MAX, DURATION)).await;
let user_id = get_random_user_id();
// Register once, this should go trough and set slots to the limit
request_to_api::<msgs::RegisterRequest, msgs::RegisterResponse>(
"/register",
msgs::RegisterRequest {
user_id: user_id.serialize(),
request_to_api::<common_msgs::RegisterRequest, common_msgs::RegisterResponse>(
Endpoint::Register,
common_msgs::RegisterRequest {
user_id: user_id.to_vec(),
},
server_addr,
)
@ -668,9 +704,9 @@ mod tests_methods {
// Register again to get additional slots, this should fail
assert_eq!(
check_api_error(
"/register",
RequestBody::Json(serde_json::json!(msgs::RegisterRequest {
user_id: user_id.serialize(),
Endpoint::Register,
RequestBody::Json(serde_json::json!(common_msgs::RegisterRequest {
user_id: user_id.to_vec(),
})),
server_addr,
)
@ -687,7 +723,7 @@ mod tests_methods {
#[tokio::test]
async fn test_register_service_unavailable() {
let (server_addr, _) = run_tower_in_background_with_config(
let (server_addr, _, _s) = run_tower_in_background_with_config(
ApiConfig::new(SLOTS, DURATION).bitcoind_unreachable(),
)
.await;
@ -696,9 +732,9 @@ mod tests_methods {
// Register with bitcoind down
assert_eq!(
check_api_error(
"/register",
RequestBody::Json(serde_json::json!(msgs::RegisterRequest {
user_id: user_id.serialize(),
Endpoint::Register,
RequestBody::Json(serde_json::json!(common_msgs::RegisterRequest {
user_id: user_id.to_vec(),
})),
server_addr,
)
@ -715,13 +751,13 @@ mod tests_methods {
#[tokio::test]
async fn test_add_appointment() {
let server_addr = run_tower_in_background().await;
let (server_addr, _s) = run_tower_in_background().await;
// Register first
let (user_sk, user_pk) = cryptography::get_random_keypair();
request_to_api::<msgs::RegisterRequest, msgs::RegisterResponse>(
"/register",
msgs::RegisterRequest {
request_to_api::<common_msgs::RegisterRequest, common_msgs::RegisterResponse>(
Endpoint::Register,
common_msgs::RegisterRequest {
user_id: user_pk.serialize().to_vec(),
},
server_addr,
@ -731,11 +767,14 @@ mod tests_methods {
// Then try to add an appointment
let appointment = generate_dummy_appointment(None).inner;
let signature = cryptography::sign(&appointment.serialize(), &user_sk).unwrap();
let signature = cryptography::sign(&appointment.to_vec(), &user_sk);
let response = request_to_api::<msgs::AddAppointmentRequest, msgs::AddAppointmentResponse>(
"/add_appointment",
msgs::AddAppointmentRequest {
let response = request_to_api::<
common_msgs::AddAppointmentRequest,
common_msgs::AddAppointmentResponse,
>(
Endpoint::AddAppointment,
common_msgs::AddAppointmentRequest {
appointment: Some(appointment.into()),
signature,
},
@ -743,20 +782,23 @@ mod tests_methods {
)
.await;
assert!(matches!(response, Ok(msgs::AddAppointmentResponse { .. })));
assert!(matches!(
response,
Ok(common_msgs::AddAppointmentResponse { .. })
));
}
#[tokio::test]
async fn test_add_appointment_non_registered() {
let server_addr = run_tower_in_background().await;
let (user_sk, _) = cryptography::get_random_keypair();
let (server_addr, _s) = run_tower_in_background().await;
let (user_sk, _s) = cryptography::get_random_keypair();
let appointment = generate_dummy_appointment(None).inner;
let signature = cryptography::sign(&appointment.serialize(), &user_sk).unwrap();
let signature = cryptography::sign(&appointment.to_vec(), &user_sk);
assert_eq!(
check_api_error(
"/add_appointment",
RequestBody::Json(serde_json::json!(msgs::AddAppointmentRequest {
Endpoint::AddAppointment,
RequestBody::Json(serde_json::json!(common_msgs::AddAppointmentRequest {
appointment: Some(appointment.into()),
signature,
})),
@ -776,14 +818,14 @@ mod tests_methods {
#[tokio::test]
async fn test_add_appointment_already_triggered() {
// Get the InternalAPI so we can mess with the inner state
let (server_addr, internal_api) =
let (server_addr, internal_api, _s) =
run_tower_in_background_with_config(ApiConfig::new(u32::MAX, DURATION)).await;
// Register
let (user_sk, user_pk) = cryptography::get_random_keypair();
request_to_api::<msgs::RegisterRequest, msgs::RegisterResponse>(
"/register",
msgs::RegisterRequest {
request_to_api::<common_msgs::RegisterRequest, common_msgs::RegisterResponse>(
Endpoint::Register,
common_msgs::RegisterRequest {
user_id: user_pk.serialize().to_vec(),
},
server_addr,
@ -791,18 +833,24 @@ mod tests_methods {
.await
.unwrap();
// Add the appointment to the Responder so it counts as triggered
let appointment = generate_dummy_appointment(None).inner;
let signature = cryptography::sign(&appointment.serialize(), &user_sk).unwrap();
// Add the appointment to the Responder as a tracker so it counts as triggered
let dispute_tx = get_random_tx();
let tracker = TransactionTracker::new(
Breach::new(dispute_tx.clone(), get_random_tx()),
UserId(user_pk),
ConfirmationStatus::ConfirmedIn(100),
);
internal_api
.get_watcher()
.add_random_tracker_to_responder(UUID::new(appointment.locator, UserId(user_pk)));
.add_dummy_tracker_to_responder(&tracker);
// Try to add it via the http API
let appointment = generate_dummy_appointment(Some(&dispute_tx.compute_txid())).inner;
let signature = cryptography::sign(&appointment.to_vec(), &user_sk);
assert_eq!(
check_api_error(
"/add_appointment",
RequestBody::Json(serde_json::json!(msgs::AddAppointmentRequest {
Endpoint::AddAppointment,
RequestBody::Json(serde_json::json!(common_msgs::AddAppointmentRequest {
appointment: Some(appointment.into()),
signature,
})),
@ -821,18 +869,18 @@ mod tests_methods {
#[tokio::test]
async fn test_add_appointment_service_unavailable() {
let (server_addr, _) = run_tower_in_background_with_config(
let (server_addr, _, _s) = run_tower_in_background_with_config(
ApiConfig::new(SLOTS, DURATION).bitcoind_unreachable(),
)
.await;
let (user_sk, _) = cryptography::get_random_keypair();
let appointment = generate_dummy_appointment(None).inner;
let signature = cryptography::sign(&appointment.serialize(), &user_sk).unwrap();
let signature = cryptography::sign(&appointment.to_vec(), &user_sk);
assert_eq!(
check_api_error(
"/add_appointment",
RequestBody::Json(serde_json::json!(msgs::AddAppointmentRequest {
Endpoint::AddAppointment,
RequestBody::Json(serde_json::json!(common_msgs::AddAppointmentRequest {
appointment: Some(appointment.into()),
signature,
})),
@ -851,13 +899,13 @@ mod tests_methods {
#[tokio::test]
async fn test_get_appointment() {
let server_addr = run_tower_in_background().await;
let (server_addr, _s) = run_tower_in_background().await;
// Register first
let (user_sk, user_pk) = cryptography::get_random_keypair();
request_to_api::<msgs::RegisterRequest, msgs::RegisterResponse>(
"/register",
msgs::RegisterRequest {
request_to_api::<common_msgs::RegisterRequest, common_msgs::RegisterResponse>(
Endpoint::Register,
common_msgs::RegisterRequest {
user_id: user_pk.serialize().to_vec(),
},
server_addr,
@ -867,11 +915,11 @@ mod tests_methods {
// Add an appointment
let appointment = generate_dummy_appointment(None).inner;
let signature = cryptography::sign(&appointment.serialize(), &user_sk).unwrap();
let signature = cryptography::sign(&appointment.to_vec(), &user_sk);
request_to_api::<msgs::AddAppointmentRequest, msgs::AddAppointmentResponse>(
"/add_appointment",
msgs::AddAppointmentRequest {
request_to_api::<common_msgs::AddAppointmentRequest, common_msgs::AddAppointmentResponse>(
Endpoint::AddAppointment,
common_msgs::AddAppointmentRequest {
appointment: Some(appointment.clone().into()),
signature,
},
@ -881,26 +929,31 @@ mod tests_methods {
.unwrap();
// Get it back
let response = request_to_api::<msgs::GetAppointmentRequest, msgs::GetAppointmentResponse>(
"/get_appointment",
msgs::GetAppointmentRequest {
locator: appointment.locator.serialize(),
let response = request_to_api::<
common_msgs::GetAppointmentRequest,
common_msgs::GetAppointmentResponse,
>(
Endpoint::GetAppointment,
common_msgs::GetAppointmentRequest {
locator: appointment.locator.to_vec(),
signature: cryptography::sign(
format!("get appointment {}", appointment.locator).as_bytes(),
&user_sk,
)
.unwrap(),
),
},
server_addr,
)
.await;
assert!(matches!(response, Ok(msgs::GetAppointmentResponse { .. })));
assert!(matches!(
response,
Ok(common_msgs::GetAppointmentResponse { .. })
));
}
#[tokio::test]
async fn test_get_appointment_non_registered() {
let server_addr = run_tower_in_background().await;
let (server_addr, _s) = run_tower_in_background().await;
// User is not registered
let (user_sk, _) = cryptography::get_random_keypair();
@ -909,14 +962,13 @@ mod tests_methods {
assert_eq!(
check_api_error(
"/get_appointment",
RequestBody::Json(serde_json::json!(msgs::GetAppointmentRequest {
locator: appointment.locator.serialize(),
Endpoint::GetAppointment,
RequestBody::Json(serde_json::json!(common_msgs::GetAppointmentRequest {
locator: appointment.locator.to_vec(),
signature: cryptography::sign(
format!("get appointment {}", appointment.locator).as_bytes(),
&user_sk,
)
.unwrap()
})),
server_addr,
)
@ -933,13 +985,13 @@ mod tests_methods {
#[tokio::test]
async fn test_get_appointment_not_found() {
let server_addr = run_tower_in_background().await;
let (server_addr, _s) = run_tower_in_background().await;
// Register first
let (user_sk, user_pk) = cryptography::get_random_keypair();
request_to_api::<msgs::RegisterRequest, msgs::RegisterResponse>(
"/register",
msgs::RegisterRequest {
request_to_api::<common_msgs::RegisterRequest, common_msgs::RegisterResponse>(
Endpoint::Register,
common_msgs::RegisterRequest {
user_id: user_pk.serialize().to_vec(),
},
server_addr,
@ -952,14 +1004,13 @@ mod tests_methods {
assert_eq!(
check_api_error(
"/get_appointment",
RequestBody::Json(serde_json::json!(msgs::GetAppointmentRequest {
locator: appointment.locator.serialize(),
Endpoint::GetAppointment,
RequestBody::Json(serde_json::json!(common_msgs::GetAppointmentRequest {
locator: appointment.locator.to_vec(),
signature: cryptography::sign(
format!("get appointment {}", appointment.locator).as_bytes(),
&user_sk,
)
.unwrap()
})),
server_addr,
)
@ -976,7 +1027,7 @@ mod tests_methods {
#[tokio::test]
async fn test_get_appointment_service_unavailable() {
let (server_addr, _) = run_tower_in_background_with_config(
let (server_addr, _, _s) = run_tower_in_background_with_config(
ApiConfig::new(SLOTS, DURATION).bitcoind_unreachable(),
)
.await;
@ -987,14 +1038,13 @@ mod tests_methods {
assert_eq!(
check_api_error(
"/get_appointment",
RequestBody::Json(serde_json::json!(msgs::GetAppointmentRequest {
locator: appointment.locator.serialize(),
Endpoint::GetAppointment,
RequestBody::Json(serde_json::json!(common_msgs::GetAppointmentRequest {
locator: appointment.locator.to_vec(),
signature: cryptography::sign(
format!("get appointment {}", appointment.locator).as_bytes(),
&user_sk,
)
.unwrap()
})),
server_addr,
)
@ -1011,13 +1061,13 @@ mod tests_methods {
#[tokio::test]
async fn test_get_subscription_info() {
let server_addr = run_tower_in_background().await;
let (server_addr, _s) = run_tower_in_background().await;
// Register first
let (user_sk, user_pk) = cryptography::get_random_keypair();
request_to_api::<msgs::RegisterRequest, msgs::RegisterResponse>(
"/register",
msgs::RegisterRequest {
request_to_api::<common_msgs::RegisterRequest, common_msgs::RegisterResponse>(
Endpoint::Register,
common_msgs::RegisterRequest {
user_id: user_pk.serialize().to_vec(),
},
server_addr,
@ -1026,36 +1076,36 @@ mod tests_methods {
.unwrap();
// Get the subscription info
let response =
request_to_api::<msgs::GetSubscriptionInfoRequest, msgs::GetSubscriptionInfoResponse>(
"/get_subscription_info",
msgs::GetSubscriptionInfoRequest {
signature: cryptography::sign("get subscription info".as_bytes(), &user_sk)
.unwrap(),
},
server_addr,
)
.await;
let response = request_to_api::<
common_msgs::GetSubscriptionInfoRequest,
common_msgs::GetSubscriptionInfoResponse,
>(
Endpoint::GetSubscriptionInfo,
common_msgs::GetSubscriptionInfoRequest {
signature: cryptography::sign("get subscription info".as_bytes(), &user_sk),
},
server_addr,
)
.await;
assert!(matches!(
response,
Ok(msgs::GetSubscriptionInfoResponse { .. })
Ok(common_msgs::GetSubscriptionInfoResponse { .. })
));
}
#[tokio::test]
async fn test_get_subscription_info_non_registered() {
let server_addr = run_tower_in_background().await;
let (server_addr, _s) = run_tower_in_background().await;
// User is not registered
let (user_sk, _) = cryptography::get_random_keypair();
assert_eq!(
check_api_error(
"/get_subscription_info",
RequestBody::Json(serde_json::json!(msgs::GetSubscriptionInfoRequest {
Endpoint::GetSubscriptionInfo,
RequestBody::Json(serde_json::json!(common_msgs::GetSubscriptionInfoRequest {
signature: cryptography::sign("get subscription info".as_bytes(), &user_sk)
.unwrap(),
})),
server_addr,
)
@ -1073,17 +1123,16 @@ mod tests_methods {
#[tokio::test]
async fn test_get_subscription_info_service_unavailable() {
let (user_sk, _) = cryptography::get_random_keypair();
let (server_addr, _) = run_tower_in_background_with_config(
let (server_addr, _, _s) = run_tower_in_background_with_config(
ApiConfig::new(SLOTS, DURATION).bitcoind_unreachable(),
)
.await;
assert_eq!(
check_api_error(
"/get_subscription_info",
RequestBody::Json(serde_json::json!(msgs::GetSubscriptionInfoRequest {
Endpoint::GetSubscriptionInfo,
RequestBody::Json(serde_json::json!(common_msgs::GetSubscriptionInfoRequest {
signature: cryptography::sign("get subscription info".as_bytes(), &user_sk)
.unwrap(),
})),
server_addr,
)

File diff suppressed because it is too large Load diff

View file

@ -1,43 +1,4 @@
pub mod http;
pub mod internal;
pub mod serde_status {
use serde::de::{self, Deserializer};
use serde::ser::Serializer;
use std::str::FromStr;
use teos_common::appointment::AppointmentStatus;
pub fn serialize<S>(status: &i32, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&AppointmentStatus::from(*status).to_string())
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<i32, D::Error>
where
D: Deserializer<'de>,
{
struct StatusVisitor;
impl<'de> de::Visitor<'de> for StatusVisitor {
type Value = i32;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a string containing the status")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
let status = AppointmentStatus::from_str(v)
.map_err(|_| E::custom("given status is unknown"))?;
Ok(status as i32)
}
}
deserializer.deserialize_any(StatusVisitor)
}
}
pub mod serde;
pub mod tor;

62
teos/src/api/serde.rs Normal file
View file

@ -0,0 +1,62 @@
use crate::protos as msgs;
use teos_common::net::AddressType;
impl msgs::NetworkAddress {
pub fn from_ipv4(address: String, port: u16) -> Self {
Self {
address_type: AddressType::IpV4 as i32,
address,
port: port as u32,
}
}
pub fn from_torv3(address: String, port: u16) -> Self {
Self {
address_type: AddressType::TorV3 as i32,
address,
port: port as u32,
}
}
}
pub mod serde_address_type {
use serde::de::{self, Deserializer};
use serde::Serializer;
use std::str::FromStr;
use super::AddressType;
pub fn serialize<S>(status: &i32, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&AddressType::from(*status).to_string())
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<i32, D::Error>
where
D: Deserializer<'de>,
{
struct StatusVisitor;
impl<'de> de::Visitor<'de> for StatusVisitor {
type Value = i32;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a string containing the address type")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
let status = AddressType::from_str(v)
.map_err(|_| E::custom("given address type is unknown"))?;
Ok(status as i32)
}
}
deserializer.deserialize_any(StatusVisitor)
}
}

210
teos/src/api/tor.rs Normal file
View file

@ -0,0 +1,210 @@
use std::convert::TryInto;
use std::io::{Error, ErrorKind};
use std::net::SocketAddr;
use std::path::PathBuf;
use tokio::fs;
use tokio::net::TcpStream;
use torut::control::UnauthenticatedConn;
use torut::onion::TorSecretKeyV3;
use triggered::{Listener, Trigger};
pub struct TorAPI {
sk: TorSecretKeyV3,
api_endpoint: SocketAddr,
onion_port: u16,
tor_control_port: u16,
}
impl TorAPI {
pub async fn new(
api_endpoint: SocketAddr,
onion_port: u16,
tor_control_port: u16,
path: PathBuf,
) -> Self {
let key = if let Some(key) = TorAPI::load_sk(path.clone()).await {
key
} else {
log::info!("Generating fresh Tor secret key");
let key = TorSecretKeyV3::generate();
TorAPI::store_sk(&key, path).await;
key
};
Self {
sk: key,
api_endpoint,
onion_port,
tor_control_port,
}
}
pub fn get_onion_address(&self) -> String {
self.sk.public().get_onion_address().to_string()
}
/// Loads a Tor key from disk (if found).
async fn load_sk(path: PathBuf) -> Option<TorSecretKeyV3> {
log::info!("Loading Tor secret key from disk");
let key = fs::read(path.join("onion_v3_sk"))
.await
.map_err(|e| log::warn!("Tor secret key cannot be loaded. {e}"))
.ok()?;
let key: [u8; 64] = key
.try_into()
.map_err(|_| log::error!("Cannot convert loaded data into Tor secret key"))
.ok()?;
Some(TorSecretKeyV3::from(key))
}
/// Stores a Tor key to disk.
async fn store_sk(key: &TorSecretKeyV3, path: PathBuf) {
if let Err(e) = fs::write(path.join("onion_v3_sk"), key.as_bytes()).await {
log::error!("Cannot store Tor secret key. {e}");
}
}
/// Tries to connect to the Tor control port
async fn connect_tor_cp(&self) -> Result<TcpStream, Error> {
let sock = TcpStream::connect(format!("127.0.0.1:{}", self.tor_control_port))
.await
.map_err(|_| {
Error::new(
ErrorKind::ConnectionRefused,
"failed to connect to Tor control port",
)
})?;
Ok(sock)
}
/// Expose an onion service that re-directs to the public api.
pub async fn expose_onion_service(
&self,
service_ready: Trigger,
shutdown_signal_tor: Listener,
) -> Result<(), Error> {
let stream = self
.connect_tor_cp()
.await
.map_err(|e| Error::new(ErrorKind::ConnectionRefused, e))?;
let mut unauth_conn = UnauthenticatedConn::new(stream);
let pre_auth = unauth_conn
.load_protocol_info()
.await
.map_err(|e| Error::new(ErrorKind::ConnectionRefused, e))?;
let auth_data = pre_auth
.make_auth_data()?
.expect("failed to make auth data");
unauth_conn.authenticate(&auth_data).await.map_err(|_| {
Error::new(
ErrorKind::PermissionDenied,
"failed to authenticate with Tor",
)
})?;
let mut auth_conn = unauth_conn.into_authenticated().await;
auth_conn.set_async_event_handler(Some(|_| async move { Ok(()) }));
auth_conn
.add_onion_v3(
&self.sk,
false,
false,
false,
None,
&mut [(self.onion_port, self.api_endpoint)].iter(),
)
.await
.map_err(|e| {
Error::new(
ErrorKind::Other,
format!("failed to create onion hidden service: {e}"),
)
})?;
log::info!(
"Onion service: {}:{}",
self.get_onion_address(),
self.onion_port
);
service_ready.trigger();
shutdown_signal_tor.await;
auth_conn
.del_onion(
&self
.sk
.public()
.get_onion_address()
.get_address_without_dot_onion(),
)
.await
.unwrap();
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempdir::TempDir;
use teos_common::test_utils::get_random_user_id;
#[tokio::test]
async fn test_store_load_sk() {
let key = TorSecretKeyV3::generate();
let tmp_path = TempDir::new(&format!("data_dir_{}", get_random_user_id())).unwrap();
TorAPI::store_sk(&key, tmp_path.path().into()).await;
let loaded_key = TorAPI::load_sk(tmp_path.path().into()).await;
assert_eq!(key, loaded_key.unwrap())
}
#[tokio::test]
async fn test_load_sk_inexistent() {
let tmp_path = TempDir::new(&format!("data_dir_{}", get_random_user_id())).unwrap();
let loaded_key = TorAPI::load_sk(tmp_path.path().into()).await;
assert_eq!(loaded_key, None);
}
#[tokio::test]
async fn test_load_sk_wrong_format() {
let tmp_path = TempDir::new(&format!("data_dir_{}", get_random_user_id())).unwrap();
fs::write(tmp_path.path().join("onion_v3_sk"), "random stuff")
.await
.unwrap();
let loaded_key = TorAPI::load_sk(tmp_path.path().into()).await;
assert_eq!(loaded_key, None);
}
#[tokio::test]
async fn test_connect_tor_cp_fail() {
let wrong_cp = 9000;
let tmp_path = TempDir::new(&format!("data_dir_{}", get_random_user_id())).unwrap();
let tor_api = TorAPI::new(
"127.0.1.1:9814".parse().unwrap(),
9814,
wrong_cp,
tmp_path.path().into(),
)
.await;
match tor_api.connect_tor_cp().await {
Ok(_) => {}
Err(e) => {
assert_eq!("failed to connect to Tor control port", e.to_string())
}
}
}
}

View file

@ -9,17 +9,19 @@
* at your option.
*/
use base64::{engine::general_purpose::URL_SAFE as BASE64, Engine};
use std::convert::TryInto;
use std::io::{Error, ErrorKind};
use std::sync::Arc;
use tokio::sync::Mutex;
use bitcoin::base64;
use bitcoin::hash_types::{BlockHash, Txid};
use bitcoin::hashes::hex::ToHex;
use bitcoin::{Block, Transaction};
use bitcoin::Transaction;
use bitcoincore_rpc::{Auth, RawTx};
use lightning::util::ser::Writeable;
use lightning_block_sync::http::HttpEndpoint;
use lightning_block_sync::http::{HttpEndpoint, JsonResponse};
use lightning_block_sync::rpc::RpcClient;
use lightning_block_sync::{AsyncBlockSourceResult, BlockHeaderData, BlockSource};
use lightning_block_sync::{AsyncBlockSourceResult, BlockData, BlockHeaderData, BlockSource};
/// A simple implementation of a bitcoind client (`bitcoin-cli`) with the minimal functionality required by the tower.
pub struct BitcoindClient<'a> {
@ -30,39 +32,39 @@ pub struct BitcoindClient<'a> {
/// The port to connect to.
port: u16,
/// The RPC user `bitcoind` is configured with.
rpc_user: &'a str,
rpc_user: String,
/// The RPC password for the given user.
rpc_password: &'a str,
rpc_password: String,
}
impl BlockSource for &BitcoindClient<'_> {
/// Gets a block header given its hash.
fn get_header<'a>(
&'a mut self,
&'a self,
header_hash: &'a BlockHash,
height_hint: Option<u32>,
) -> AsyncBlockSourceResult<'a, BlockHeaderData> {
Box::pin(async move {
let mut rpc = self.bitcoind_rpc_client.lock().await;
let rpc = self.bitcoind_rpc_client.lock().await;
rpc.get_header(header_hash, height_hint).await
})
}
/// Gets a block given its hash.
fn get_block<'a>(
&'a mut self,
&'a self,
header_hash: &'a BlockHash,
) -> AsyncBlockSourceResult<'a, Block> {
) -> AsyncBlockSourceResult<'a, BlockData> {
Box::pin(async move {
let mut rpc = self.bitcoind_rpc_client.lock().await;
let rpc = self.bitcoind_rpc_client.lock().await;
rpc.get_block(header_hash).await
})
}
/// Get the best block known by our node.
fn get_best_block(&mut self) -> AsyncBlockSourceResult<(BlockHash, Option<u32>)> {
fn get_best_block(&self) -> AsyncBlockSourceResult<(BlockHash, Option<u32>)> {
Box::pin(async move {
let mut rpc = self.bitcoind_rpc_client.lock().await;
let rpc = self.bitcoind_rpc_client.lock().await;
rpc.get_best_block().await
})
}
@ -75,12 +77,34 @@ impl<'a> BitcoindClient<'a> {
pub async fn new(
host: &'a str,
port: u16,
rpc_user: &'a str,
rpc_password: &'a str,
auth: Auth,
teos_network: &'a str,
) -> std::io::Result<BitcoindClient<'a>> {
let http_endpoint = HttpEndpoint::for_host(host.to_owned()).with_port(port);
let rpc_credentials = base64::encode(&format!("{}:{}", rpc_user, rpc_password));
let bitcoind_rpc_client = RpcClient::new(&rpc_credentials, http_endpoint)?;
let (rpc_user, rpc_password) = {
let (user, pass) = auth.get_user_pass().map_err(|e| {
Error::new(
ErrorKind::InvalidInput,
format!("Cannot read cookie file. {}", e),
)
})?;
if user.is_none() {
Err(Error::new(
ErrorKind::InvalidInput,
"Empty btc_rpc_user parsed from rpc_cookie".to_string(),
))
} else if pass.is_none() {
Err(Error::new(
ErrorKind::InvalidInput,
"Empty btc_rpc_password parsed from rpc_cookie",
))
} else {
Ok((user.unwrap(), pass.unwrap()))
}
}?;
let rpc_credentials = BASE64.encode(format!("{}:{}", rpc_user, rpc_password));
let bitcoind_rpc_client = RpcClient::new(&rpc_credentials, http_endpoint);
let client = Self {
bitcoind_rpc_client: Arc::new(Mutex::new(bitcoind_rpc_client)),
@ -90,17 +114,25 @@ impl<'a> BitcoindClient<'a> {
rpc_password,
};
// Test that bitcoind is reachable
match client.get_best_block_hash_and_height().await {
Ok(_) => Ok(client),
Err(e) => Err(e),
// Test that bitcoind is reachable.
let btc_network = client.get_chain().await?;
// Assert teos runs on the same chain/network as bitcoind.
if btc_network != teos_network {
Err(Error::new(
ErrorKind::InvalidInput,
format!("bitcoind is running on {btc_network} but teosd is set to run on {teos_network}"),
))
} else {
Ok(client)
}
}
/// Gets a fresh RPC client.
pub fn get_new_rpc_client(&self) -> std::io::Result<RpcClient> {
pub fn get_new_rpc_client(&self) -> RpcClient {
let http_endpoint = HttpEndpoint::for_host(self.host.to_owned()).with_port(self.port);
let rpc_credentials = base64::encode(&format!("{}:{}", self.rpc_user, self.rpc_password));
let rpc_credentials = BASE64.encode(format!("{}:{}", self.rpc_user, self.rpc_password));
RpcClient::new(&rpc_credentials, http_endpoint)
}
@ -108,26 +140,46 @@ impl<'a> BitcoindClient<'a> {
pub async fn get_best_block_hash_and_height(
&self,
) -> Result<(BlockHash, Option<u32>), std::io::Error> {
let mut rpc = self.bitcoind_rpc_client.lock().await;
let rpc = self.bitcoind_rpc_client.lock().await;
rpc.call_method::<(BlockHash, Option<u32>)>("getblockchaininfo", &[])
.await
}
/// Sends a transaction to the network.
pub async fn send_raw_transaction(&self, raw_tx: &Transaction) -> Result<Txid, std::io::Error> {
let mut rpc = self.bitcoind_rpc_client.lock().await;
let rpc = self.bitcoind_rpc_client.lock().await;
let raw_tx_json = serde_json::json!(raw_tx.encode().to_hex());
let raw_tx_json = serde_json::json!(raw_tx.encode().raw_hex());
rpc.call_method::<Txid>("sendrawtransaction", &[raw_tx_json])
.await
}
/// Gets a transaction given its id.
pub async fn get_raw_transaction(&self, txid: &Txid) -> Result<Transaction, std::io::Error> {
let mut rpc = self.bitcoind_rpc_client.lock().await;
let rpc = self.bitcoind_rpc_client.lock().await;
let txid_hex = serde_json::json!(txid.encode().to_hex());
let txid_hex = serde_json::json!(txid.encode().raw_hex());
rpc.call_method::<Transaction>("getrawtransaction", &[txid_hex])
.await
}
/// Gets bitcoind's network.
pub async fn get_chain(&self) -> std::io::Result<String> {
// A wrapper type to extract "chain" key from getblockchaininfo JsonResponse.
struct BtcNetwork(String);
impl TryInto<BtcNetwork> for JsonResponse {
type Error = std::io::Error;
fn try_into(self) -> std::io::Result<BtcNetwork> {
Ok(BtcNetwork(self.0["chain"].as_str().unwrap().to_string()))
}
}
// Ask the RPC client for the network bitcoind is running on.
let rpc = self.bitcoind_rpc_client.lock().await;
let btc_network = rpc
.call_method::<BtcNetwork>("getblockchaininfo", &[])
.await?;
Ok(btc_network.0)
}
}

View file

@ -6,7 +6,7 @@ use std::sync::{Arc, Condvar, Mutex};
use crate::responder::ConfirmationStatus;
use crate::{errors, rpc_errors};
use bitcoin::{BlockHash, Transaction, Txid};
use bitcoin::{Transaction, Txid};
use bitcoincore_rpc::{
jsonrpc::error::Error::Rpc as RpcError, jsonrpc::error::Error::Transport as TransportError,
Client as BitcoindClient, Error::JsonRpc as JsonRpcError, RpcApi,
@ -22,7 +22,7 @@ pub struct Carrier {
/// A map of receipts already issued by the [Carrier].
/// Used to prevent potentially re-sending the same transaction over and over.
issued_receipts: HashMap<Txid, ConfirmationStatus>,
/// The last known block header.
/// The last known block height.
block_height: u32,
}
@ -41,6 +41,11 @@ impl Carrier {
}
}
/// The last known block height.
pub(crate) fn block_height(&self) -> u32 {
self.block_height
}
/// Clears the receipts cached by the [Carrier]. Should be called periodically to prevent it from
/// growing unbounded.
pub(crate) fn clear_receipts(&mut self) {
@ -75,49 +80,49 @@ impl Carrier {
pub(crate) fn send_transaction(&mut self, tx: &Transaction) -> ConfirmationStatus {
self.hang_until_bitcoind_reachable();
if let Some(receipt) = self.issued_receipts.get(&tx.txid()) {
log::info!("Transaction already sent: {}", tx.txid());
if let Some(receipt) = self.issued_receipts.get(&tx.compute_txid()) {
log::info!("Transaction already sent: {}", tx.compute_txid());
return *receipt;
}
log::info!("Pushing transaction to the network: {}", tx.txid());
log::info!("Pushing transaction to the network: {}", tx.compute_txid());
let receipt = match self.bitcoin_cli.send_raw_transaction(tx) {
Ok(_) => {
// Here the transaction could, potentially, have been in mempool before the current height.
// This shouldn't really matter though.
log::info!("Transaction successfully delivered: {}", tx.txid());
log::info!("Transaction successfully delivered: {}", tx.compute_txid());
ConfirmationStatus::InMempoolSince(self.block_height)
}
Err(JsonRpcError(RpcError(rpcerr))) => match rpcerr.code {
// Since we're pushing a raw transaction to the network we can face several rejections
rpc_errors::RPC_VERIFY_REJECTED => {
log::error!("Transaction couldn't be broadcast. {:?}", rpcerr);
log::error!("Transaction couldn't be broadcast. {rpcerr:?}");
ConfirmationStatus::Rejected(rpc_errors::RPC_VERIFY_REJECTED)
}
rpc_errors::RPC_VERIFY_ERROR => {
log::error!("Transaction couldn't be broadcast. {:?}", rpcerr);
log::error!("Transaction couldn't be broadcast. {rpcerr:?}");
ConfirmationStatus::Rejected(rpc_errors::RPC_VERIFY_ERROR)
}
rpc_errors::RPC_VERIFY_ALREADY_IN_CHAIN => {
log::info!(
"Transaction is already in the blockchain: {}. Getting confirmation count",
tx.txid()
"Transaction was confirmed long ago, not keeping track of it: {}",
tx.compute_txid()
);
ConfirmationStatus::ConfirmedIn(self.get_tx_height(&tx.txid()).unwrap())
// Given we are not using txindex, if a transaction bounces we cannot get its confirmation count. However, [send_transaction] is guarded by
// checking whether the transaction id can be found in the [Responder]'s [TxIndex], meaning that if the transaction bounces it was confirmed long
// ago (> IRREVOCABLY_RESOLVED), so we don't need to worry about it.
ConfirmationStatus::IrrevocablyResolved
}
rpc_errors::RPC_DESERIALIZATION_ERROR => {
// Adding this here just for completeness. We should never end up here. The Carrier only sends txs handed by the Responder,
// who receives them from the Watcher, who checks that the tx can be properly deserialized.
log::info!("Transaction cannot be deserialized: {}", tx.txid());
log::info!("Transaction cannot be deserialized: {}", tx.compute_txid());
ConfirmationStatus::Rejected(rpc_errors::RPC_DESERIALIZATION_ERROR)
}
_ => {
// If something else happens (unlikely but possible) log it so we can treat it in future releases.
log::error!(
"Unexpected rpc error when calling sendrawtransaction: {:?}",
rpcerr
);
log::error!("Unexpected rpc error when calling sendrawtransaction: {rpcerr:?}");
ConfirmationStatus::Rejected(errors::UNKNOWN_JSON_RPC_EXCEPTION)
}
},
@ -129,87 +134,48 @@ impl Carrier {
}
Err(e) => {
// TODO: This may need finer catching.
log::error!("Unexpected error when calling sendrawtransaction: {:?}", e);
log::error!("Unexpected error when calling sendrawtransaction: {e:?}");
ConfirmationStatus::Rejected(errors::UNKNOWN_JSON_RPC_EXCEPTION)
}
};
self.issued_receipts.insert(tx.txid(), receipt);
self.issued_receipts.insert(tx.compute_txid(), receipt);
receipt
}
/// Gets the block height at where a given [Transaction] was confirmed at (if any).
fn get_tx_height(&self, txid: &Txid) -> Option<u32> {
if let Some(block_hash) = self.get_block_hash_for_tx(txid) {
self.get_block_height(&block_hash)
} else {
None
}
}
/// Queries the height of a given [Block](bitcoin::Block). Returns it if the block can be found. Returns [None] otherwise.
fn get_block_height(&self, block_hash: &BlockHash) -> Option<u32> {
self.hang_until_bitcoind_reachable();
match self.bitcoin_cli.get_block_header_info(block_hash) {
Ok(header_data) => Some(header_data.height as u32),
Err(JsonRpcError(RpcError(rpcerr))) => match rpcerr.code {
rpc_errors::RPC_INVALID_ADDRESS_OR_KEY => {
log::info!("Block not found: {}", block_hash);
None
}
e => {
log::error!("Unexpected error code when calling getblockheader: {}", e);
None
}
},
Err(JsonRpcError(TransportError(_))) => {
// Connection refused, bitcoind is down.
log::error!("Connection lost with bitcoind, retrying request when possible");
self.flag_bitcoind_unreachable();
self.get_block_height(block_hash)
}
// TODO: This may need finer catching.
Err(e) => {
log::error!("Unexpected JSONRPCError when calling getblockheader: {}", e);
None
}
}
}
/// Gets the block hash where a given [Transaction] was confirmed at (if any).
pub(crate) fn get_block_hash_for_tx(&self, txid: &Txid) -> Option<BlockHash> {
/// Checks whether a given transaction can be found in the mempool.
///
/// This uses `getrawtransaction` under the hood and, therefore, its behavior depends on whether `txindex` is enabled in bitcoind.
/// If `txindex` is disabled (default), it will only pull data from the mempool. Otherwise, it will also pull data from the transaction
/// index. Hence, we need to check whether the returned struct has any of the block related datum set (such as `blockhash`).
pub(crate) fn in_mempool(&self, txid: &Txid) -> bool {
self.hang_until_bitcoind_reachable();
match self.bitcoin_cli.get_raw_transaction_info(txid, None) {
Ok(tx_data) => tx_data.blockhash,
Ok(tx) => tx.blockhash.is_none(),
Err(JsonRpcError(RpcError(rpcerr))) => match rpcerr.code {
rpc_errors::RPC_INVALID_ADDRESS_OR_KEY => {
log::info!("Transaction not found in mempool nor blockchain: {}", txid);
None
log::info!("Transaction not found in mempool: {txid}");
false
}
e => {
log::error!(
"Unexpected error code when calling getrawtransaction: {}",
e
);
None
// DISCUSS: This could result in a silent error with unknown consequences
log::error!("Unexpected error code when calling getrawtransaction: {e}");
false
}
},
Err(JsonRpcError(TransportError(_))) => {
// Connection refused, bitcoind is down.
log::error!("Connection lost with bitcoind, retrying request when possible");
self.flag_bitcoind_unreachable();
self.get_block_hash_for_tx(txid)
self.in_mempool(txid)
}
// TODO: This may need finer catching.
Err(e) => {
log::error!(
"Unexpected JSONRPCError when calling getrawtransaction: {}",
e
);
None
// DISCUSS: This could result in a silent error with unknown consequences
log::error!("Unexpected JSONRPCError when calling getrawtransaction: {e}");
false
}
}
}
@ -218,13 +184,13 @@ impl Carrier {
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
use std::thread;
use crate::test_utils::{
get_random_tx, start_server, BitcoindMock, MockOptions, START_HEIGHT, TX_HEX,
};
use crate::test_utils::{get_random_tx, start_server, BitcoindMock, MockOptions, START_HEIGHT};
use teos_common::test_utils::{TXID_HEX, TX_HEX};
use bitcoin::consensus::deserialize;
use bitcoin::consensus;
use bitcoin::hashes::hex::FromHex;
use bitcoincore_rpc::Auth;
@ -242,18 +208,17 @@ mod tests {
#[test]
fn test_clear_receipts() {
let bitcoind_mock = BitcoindMock::new(MockOptions::empty());
let bitcoind_mock = BitcoindMock::new(MockOptions::default());
let bitcoind_reachable = Arc::new((Mutex::new(true), Condvar::new()));
let bitcoin_cli = Arc::new(BitcoindClient::new(bitcoind_mock.url(), Auth::None).unwrap());
let start_height = START_HEIGHT as u32;
start_server(bitcoind_mock);
let mut carrier = Carrier::new(bitcoin_cli, bitcoind_reachable, start_height);
// Lets add some dummy data into the cache
for i in 0..10 {
carrier.issued_receipts.insert(
get_random_tx().txid(),
get_random_tx().compute_txid(),
ConfirmationStatus::ConfirmedIn(start_height - i),
);
}
@ -266,20 +231,38 @@ mod tests {
#[test]
fn test_send_transaction_ok() {
let bitcoind_mock = BitcoindMock::new(MockOptions::empty());
let bitcoind_mock = BitcoindMock::new(MockOptions::default());
let bitcoind_reachable = Arc::new((Mutex::new(true), Condvar::new()));
let bitcoin_cli = Arc::new(BitcoindClient::new(bitcoind_mock.url(), Auth::None).unwrap());
let start_height = START_HEIGHT as u32;
start_server(bitcoind_mock);
start_server(bitcoind_mock.server);
let mut carrier = Carrier::new(bitcoin_cli, bitcoind_reachable, start_height);
let tx = deserialize::<Transaction>(&Vec::from_hex(TX_HEX).unwrap()).unwrap();
let tx = consensus::deserialize(&Vec::from_hex(TX_HEX).unwrap()).unwrap();
let r = carrier.send_transaction(&tx);
assert_eq!(r, ConfirmationStatus::InMempoolSince(start_height));
// Check the receipt is on the cache
assert_eq!(carrier.issued_receipts.get(&tx.txid()).unwrap(), &r);
assert_eq!(carrier.issued_receipts.get(&tx.compute_txid()).unwrap(), &r);
}
#[test]
fn test_send_transaction_ok_already_in_mempool() {
let bitcoind_mock = BitcoindMock::new(MockOptions::in_mempool());
let bitcoind_reachable = Arc::new((Mutex::new(true), Condvar::new()));
let bitcoin_cli = Arc::new(BitcoindClient::new(bitcoind_mock.url(), Auth::None).unwrap());
let start_height = START_HEIGHT as u32;
start_server(bitcoind_mock.server);
let mut carrier = Carrier::new(bitcoin_cli, bitcoind_reachable, start_height);
let tx = consensus::deserialize(&Vec::from_hex(TX_HEX).unwrap()).unwrap();
let r = carrier.send_transaction(&tx);
assert_eq!(r, ConfirmationStatus::InMempoolSince(start_height));
// Check the receipt is on the cache
assert_eq!(carrier.issued_receipts.get(&tx.compute_txid()).unwrap(), &r);
}
#[test]
@ -290,10 +273,10 @@ mod tests {
let bitcoind_reachable = Arc::new((Mutex::new(true), Condvar::new()));
let bitcoin_cli = Arc::new(BitcoindClient::new(bitcoind_mock.url(), Auth::None).unwrap());
let start_height = START_HEIGHT as u32;
start_server(bitcoind_mock);
start_server(bitcoind_mock.server);
let mut carrier = Carrier::new(bitcoin_cli, bitcoind_reachable, start_height);
let tx = deserialize::<Transaction>(&Vec::from_hex(TX_HEX).unwrap()).unwrap();
let tx = consensus::deserialize(&Vec::from_hex(TX_HEX).unwrap()).unwrap();
let r = carrier.send_transaction(&tx);
assert_eq!(
@ -302,7 +285,7 @@ mod tests {
);
// Check the receipt is on the cache
assert_eq!(carrier.issued_receipts.get(&tx.txid()).unwrap(), &r);
assert_eq!(carrier.issued_receipts.get(&tx.compute_txid()).unwrap(), &r);
}
#[test]
@ -312,10 +295,10 @@ mod tests {
let bitcoind_reachable = Arc::new((Mutex::new(true), Condvar::new()));
let bitcoin_cli = Arc::new(BitcoindClient::new(bitcoind_mock.url(), Auth::None).unwrap());
let start_height = START_HEIGHT as u32;
start_server(bitcoind_mock);
start_server(bitcoind_mock.server);
let mut carrier = Carrier::new(bitcoin_cli, bitcoind_reachable, start_height);
let tx = deserialize::<Transaction>(&Vec::from_hex(TX_HEX).unwrap()).unwrap();
let tx = consensus::deserialize(&Vec::from_hex(TX_HEX).unwrap()).unwrap();
let r = carrier.send_transaction(&tx);
assert_eq!(
@ -324,29 +307,27 @@ mod tests {
);
// Check the receipt is on the cache
assert_eq!(carrier.issued_receipts.get(&tx.txid()).unwrap(), &r);
assert_eq!(carrier.issued_receipts.get(&tx.compute_txid()).unwrap(), &r);
}
#[test]
fn test_send_transaction_verify_already_in_chain() {
let bitcoind_mock = BitcoindMock::new(MockOptions::new(
let bitcoind_mock = BitcoindMock::new(MockOptions::with_error(
rpc_errors::RPC_VERIFY_ALREADY_IN_CHAIN as i64,
BlockHash::default(),
START_HEIGHT,
));
let bitcoind_reachable = Arc::new((Mutex::new(true), Condvar::new()));
let bitcoin_cli = Arc::new(BitcoindClient::new(bitcoind_mock.url(), Auth::None).unwrap());
let start_height = START_HEIGHT as u32;
start_server(bitcoind_mock);
start_server(bitcoind_mock.server);
let mut carrier = Carrier::new(bitcoin_cli, bitcoind_reachable, start_height);
let tx = deserialize::<Transaction>(&Vec::from_hex(TX_HEX).unwrap()).unwrap();
let tx = consensus::deserialize(&Vec::from_hex(TX_HEX).unwrap()).unwrap();
let r = carrier.send_transaction(&tx);
assert_eq!(r, ConfirmationStatus::ConfirmedIn(start_height));
assert_eq!(r, ConfirmationStatus::IrrevocablyResolved);
// Check the receipt is on the cache
assert_eq!(carrier.issued_receipts.get(&tx.txid()).unwrap(), &r);
assert_eq!(carrier.issued_receipts.get(&tx.compute_txid()).unwrap(), &r);
}
#[test]
@ -356,10 +337,10 @@ mod tests {
let bitcoind_reachable = Arc::new((Mutex::new(true), Condvar::new()));
let bitcoin_cli = Arc::new(BitcoindClient::new(bitcoind_mock.url(), Auth::None).unwrap());
let start_height = START_HEIGHT as u32;
start_server(bitcoind_mock);
start_server(bitcoind_mock.server);
let mut carrier = Carrier::new(bitcoin_cli, bitcoind_reachable, start_height);
let tx = deserialize::<Transaction>(&Vec::from_hex(TX_HEX).unwrap()).unwrap();
let tx = consensus::deserialize(&Vec::from_hex(TX_HEX).unwrap()).unwrap();
let r = carrier.send_transaction(&tx);
assert_eq!(
@ -368,19 +349,19 @@ mod tests {
);
// Check the receipt is on the cache
assert_eq!(carrier.issued_receipts.get(&tx.txid()).unwrap(), &r);
assert_eq!(carrier.issued_receipts.get(&tx.compute_txid()).unwrap(), &r);
}
#[test]
fn test_send_transaction_connection_error() {
// Try to connect to an offline bitcoind.
let bitcoind_mock = BitcoindMock::new(MockOptions::empty());
let bitcoind_mock = BitcoindMock::new(MockOptions::default());
let bitcoind_reachable = Arc::new((Mutex::new(false), Condvar::new()));
let bitcoin_cli = Arc::new(BitcoindClient::new(bitcoind_mock.url(), Auth::None).unwrap());
let start_height = START_HEIGHT as u32;
let mut carrier = Carrier::new(bitcoin_cli, bitcoind_reachable.clone(), start_height);
let tx = deserialize::<Transaction>(&Vec::from_hex(TX_HEX).unwrap()).unwrap();
let tx = consensus::deserialize(&Vec::from_hex(TX_HEX).unwrap()).unwrap();
let delay = std::time::Duration::new(3, 0);
thread::spawn(move || {
@ -401,91 +382,86 @@ mod tests {
}
#[test]
fn test_get_tx_height_ok() {
let target_height = 21;
fn test_in_mempool() {
let bitcoind_mock = BitcoindMock::new(MockOptions::in_mempool());
let bitcoind_reachable = Arc::new((Mutex::new(true), Condvar::new()));
let bitcoin_cli = Arc::new(BitcoindClient::new(bitcoind_mock.url(), Auth::None).unwrap());
let start_height = START_HEIGHT as u32;
start_server(bitcoind_mock.server);
let carrier = Carrier::new(bitcoin_cli, bitcoind_reachable, start_height);
let txid = Txid::from_str(TXID_HEX).unwrap();
assert!(carrier.in_mempool(&txid));
}
#[test]
fn test_not_in_mempool() {
let bitcoind_mock = BitcoindMock::new(MockOptions::default());
let bitcoind_reachable = Arc::new((Mutex::new(true), Condvar::new()));
let bitcoin_cli = Arc::new(BitcoindClient::new(bitcoind_mock.url(), Auth::None).unwrap());
let start_height = START_HEIGHT as u32;
start_server(bitcoind_mock.server);
let carrier = Carrier::new(bitcoin_cli, bitcoind_reachable, start_height);
let txid = Txid::from_str(TXID_HEX).unwrap();
assert!(!carrier.in_mempool(&txid));
}
#[test]
fn test_not_in_mempool_via_error() {
let bitcoind_mock = BitcoindMock::new(MockOptions::with_error(
rpc_errors::RPC_INVALID_ADDRESS_OR_KEY as i64,
));
let bitcoind_reachable = Arc::new((Mutex::new(true), Condvar::new()));
let bitcoin_cli = Arc::new(BitcoindClient::new(bitcoind_mock.url(), Auth::None).unwrap());
let start_height = START_HEIGHT as u32;
start_server(bitcoind_mock.server);
let carrier = Carrier::new(bitcoin_cli, bitcoind_reachable, start_height);
let txid = Txid::from_str(TXID_HEX).unwrap();
assert!(!carrier.in_mempool(&txid));
}
#[test]
fn test_in_mempool_unexpected_error() {
let bitcoind_mock =
BitcoindMock::new(MockOptions::with_block(BlockHash::default(), target_height));
BitcoindMock::new(MockOptions::with_error(rpc_errors::RPC_MISC_ERROR as i64));
let bitcoind_reachable = Arc::new((Mutex::new(true), Condvar::new()));
let bitcoin_cli = Arc::new(BitcoindClient::new(bitcoind_mock.url(), Auth::None).unwrap());
let start_height = START_HEIGHT as u32;
start_server(bitcoind_mock);
start_server(bitcoind_mock.server);
let carrier = Carrier::new(bitcoin_cli, bitcoind_reachable, start_height);
let tx = deserialize::<Transaction>(&Vec::from_hex(TX_HEX).unwrap()).unwrap();
let txid = Txid::from_str(TXID_HEX).unwrap();
assert!(!carrier.in_mempool(&txid));
}
#[test]
fn test_in_mempool_connection_error() {
// Try to connect to an offline bitcoind.
let bitcoind_mock = BitcoindMock::new(MockOptions::default());
let bitcoind_reachable = Arc::new((Mutex::new(false), Condvar::new()));
let bitcoin_cli = Arc::new(BitcoindClient::new(bitcoind_mock.url(), Auth::None).unwrap());
let start_height = START_HEIGHT as u32;
let carrier = Carrier::new(bitcoin_cli, bitcoind_reachable.clone(), start_height);
let txid = Txid::from_str(TXID_HEX).unwrap();
let delay = std::time::Duration::new(3, 0);
thread::spawn(move || {
thread::sleep(delay);
let (reachable, notifier) = &*bitcoind_reachable;
*reachable.lock().unwrap() = true;
notifier.notify_all();
});
let before = std::time::Instant::now();
carrier.in_mempool(&txid);
// Check the request has hanged for ~delay
assert_eq!(
carrier.get_tx_height(&tx.txid()),
Some(target_height as u32)
(std::time::Instant::now() - before).as_secs(),
delay.as_secs()
);
}
#[test]
fn test_get_tx_height_not_found() {
// Hee we are not testing the case where the block hash is unknown (which will also return None). This is because we only
// learn block hashes from bitcoind, and once a block is known, it cannot disappear (ir can be disconnected, but not banish).
let bitcoind_mock = BitcoindMock::new(MockOptions::empty());
let bitcoind_reachable = Arc::new((Mutex::new(true), Condvar::new()));
let bitcoin_cli = Arc::new(BitcoindClient::new(bitcoind_mock.url(), Auth::None).unwrap());
let start_height = START_HEIGHT as u32;
start_server(bitcoind_mock);
let carrier = Carrier::new(bitcoin_cli, bitcoind_reachable, start_height);
let tx = deserialize::<Transaction>(&Vec::from_hex(TX_HEX).unwrap()).unwrap();
assert_eq!(carrier.get_tx_height(&tx.txid()), None);
}
#[test]
fn test_get_block_height_ok() {
let target_height = 21;
let block_hash = BlockHash::default();
let bitcoind_mock = BitcoindMock::new(MockOptions::with_block(block_hash, target_height));
let bitcoind_reachable = Arc::new((Mutex::new(true), Condvar::new()));
let bitcoin_cli = Arc::new(BitcoindClient::new(bitcoind_mock.url(), Auth::None).unwrap());
let start_height = START_HEIGHT as u32;
start_server(bitcoind_mock);
let carrier = Carrier::new(bitcoin_cli, bitcoind_reachable, start_height);
assert_eq!(
carrier.get_block_height(&block_hash),
Some(target_height as u32)
);
}
#[test]
fn test_get_block_height_not_found() {
let bitcoind_mock = BitcoindMock::new(MockOptions::empty());
let bitcoind_reachable = Arc::new((Mutex::new(true), Condvar::new()));
let bitcoin_cli = Arc::new(BitcoindClient::new(bitcoind_mock.url(), Auth::None).unwrap());
let start_height = START_HEIGHT as u32;
start_server(bitcoind_mock);
let carrier = Carrier::new(bitcoin_cli, bitcoind_reachable, start_height);
assert_eq!(carrier.get_block_height(&BlockHash::default()), None);
}
#[test]
fn test_get_block_hash_for_tx_ok() {
let block_hash = BlockHash::default();
let bitcoind_mock = BitcoindMock::new(MockOptions::with_block(block_hash, 21));
let bitcoind_reachable = Arc::new((Mutex::new(true), Condvar::new()));
let bitcoin_cli = Arc::new(BitcoindClient::new(bitcoind_mock.url(), Auth::None).unwrap());
let start_height = START_HEIGHT as u32;
start_server(bitcoind_mock);
let tx = deserialize::<Transaction>(&Vec::from_hex(TX_HEX).unwrap()).unwrap();
let carrier = Carrier::new(bitcoin_cli, bitcoind_reachable, start_height);
assert_eq!(carrier.get_block_hash_for_tx(&tx.txid()), Some(block_hash));
}
#[test]
fn test_get_block_hash_for_tx_not_found() {
let bitcoind_mock = BitcoindMock::new(MockOptions::empty());
let bitcoind_reachable = Arc::new((Mutex::new(true), Condvar::new()));
let bitcoin_cli = Arc::new(BitcoindClient::new(bitcoind_mock.url(), Auth::None).unwrap());
let start_height = START_HEIGHT as u32;
start_server(bitcoind_mock);
let tx = deserialize::<Transaction>(&Vec::from_hex(TX_HEX).unwrap()).unwrap();
let carrier = Carrier::new(bitcoin_cli, bitcoind_reachable, start_height);
assert_eq!(carrier.get_block_hash_for_tx(&tx.txid()), None);
}
}

View file

@ -26,7 +26,7 @@ where
{
/// A bitcoin client to poll best tips from.
spv_client: SpvClient<'a, P, C, L>,
/// The lat known block header by the [ChainMonitor].
/// The last known block header by the [ChainMonitor].
last_known_block_header: ValidatedBlockHeader,
/// A [DBM] (database manager) instance. Used to persist block data into disk.
dbm: Arc<Mutex<DBM>>,
@ -100,7 +100,7 @@ where
Err(e) => match e.kind() {
BlockSourceErrorKind::Persistent => {
// FIXME: This may need finer catching
log::error!("Unexpected persistent error: {:?}", e);
log::error!("Unexpected persistent error: {e:?}");
}
BlockSourceErrorKind::Transient => {
// Treating all transient as connection errors at least for now.
@ -135,8 +135,8 @@ mod tests {
use std::iter::FromIterator;
use std::thread;
use bitcoin::network::constants::Network;
use bitcoin::BlockHash;
use bitcoin::Network;
use lightning_block_sync::{poll::ChainPoller, SpvClient, UnboundedCache};
use crate::test_utils::{Blockchain, START_HEIGHT};
@ -156,13 +156,18 @@ mod tests {
}
impl chain::Listen for DummyListener {
fn block_connected(&self, block: &bitcoin::Block, _: u32) {
fn filtered_block_connected(
&self,
header: &bitcoin::block::Header,
_: &chain::transaction::TransactionData,
_: u32,
) {
self.connected_blocks
.borrow_mut()
.insert(block.block_hash());
.insert(header.block_hash());
}
fn block_disconnected(&self, header: &bitcoin::BlockHeader, _: u32) {
fn block_disconnected(&self, header: &bitcoin::block::Header, _: u32) {
self.disconnected_blocks
.borrow_mut()
.insert(header.block_hash());
@ -259,10 +264,7 @@ mod tests {
// If a new (worse, just one) block gets mined, nothing gets connected nor disconnected
cm.poll_best_tip().await;
assert_eq!(cm.last_known_block_header, best_tip);
assert!(matches!(
cm.dbm.lock().unwrap().load_last_known_block(),
Err { .. }
));
assert!(cm.dbm.lock().unwrap().load_last_known_block().is_none());
assert!(listener.connected_blocks.borrow().is_empty());
assert!(listener.disconnected_blocks.borrow().is_empty());
}

View file

@ -1,49 +1,97 @@
use hex::FromHex;
use serde_json::to_string_pretty as pretty_json;
use std::fs;
use std::str::FromStr;
use structopt::StructOpt;
use tokio::fs;
use tonic::transport::{Certificate, Channel, ClientTlsConfig, Identity};
use tonic::Request;
use teos::cli_config::{Command, Config, Opt};
use teos::config;
use teos::protos as msgs;
use teos::protos::private_tower_services_client::PrivateTowerServicesClient;
use teos_common::appointment::Locator;
use teos_common::UserId;
/// Prints the cli error to standard error and exits the process
fn handle_error<T: std::fmt::Display>(error: T) {
eprintln!("{}", error);
std::process::exit(1);
}
#[tokio::main]
async fn main() {
let opt = Opt::from_args();
let path = config::data_dir_absolute_path(opt.data_dir.clone());
// Create data dir if it does not exist
fs::create_dir_all(&path).unwrap_or_else(|e| {
eprintln!("Cannot create data dir: {:?}", e);
fs::create_dir_all(&path).await.unwrap_or_else(|e| {
eprintln!("Cannot create data dir: {e:?}");
std::process::exit(1);
});
let command = opt.command.clone();
// Load conf (from file or defaults) and patch it with the command line parameters received (if any)
let mut conf = config::from_file::<Config>(path.join("teos.toml"));
let mut conf = config::from_file::<Config>(&path.join("teos.toml"));
conf.patch_with_options(opt);
// Create gRPC client and send request
let mut client =
PrivateTowerServicesClient::connect(format!("http://{}:{}", conf.rpc_bind, conf.rpc_port))
let key = fs::read(&path.join("client-key.pem"))
.await
.expect("unable to read client key from disk");
let certificate = fs::read(path.join("client.pem"))
.await
.expect("unable to read client cert from disk");
let ca_cert = Certificate::from_pem(
fs::read(path.join("ca.pem"))
.await
.unwrap_or_else(|e| {
eprintln!("Cannot connect to the tower. Connection refused");
if conf.debug {
eprintln!("{:?}", e);
}
std::process::exit(1);
});
.expect("unable to read ca cert from disk"),
);
let tls = ClientTlsConfig::new()
.domain_name("localhost")
.ca_certificate(ca_cert)
.identity(Identity::from_pem(certificate, key));
let channel = Channel::from_shared(format!("https://{}:{}", conf.rpc_bind, conf.rpc_port))
.expect("Cannot create channel from endpoint")
.tls_config(tls)
.unwrap_or_else(|e| {
eprintln!("Could not configure tls: {e:?}");
std::process::exit(1);
})
.connect()
.await
.unwrap_or_else(|_| {
eprintln!("Could not connect to tower. Is teosd running?");
std::process::exit(1);
});
let mut client = PrivateTowerServicesClient::new(channel);
match command {
Command::GetAllAppointments => {
let appointments = client.get_all_appointments(Request::new(())).await.unwrap();
println!("{}", pretty_json(&appointments.into_inner()).unwrap());
}
Command::GetAppointments(appointments_data) => {
match Locator::from_hex(&appointments_data.locator) {
Ok(locator) => {
match client
.get_appointments(Request::new(msgs::GetAppointmentsRequest {
locator: locator.to_vec(),
}))
.await
{
Ok(appointments) => {
println!("{}", pretty_json(&appointments.into_inner()).unwrap())
}
Err(status) => handle_error(status.message()),
}
}
Err(e) => handle_error(e),
};
}
Command::GetTowerInfo => {
let info = client.get_tower_info(Request::new(())).await.unwrap();
println!("{}", pretty_json(&info.into_inner()).unwrap())
@ -52,22 +100,22 @@ async fn main() {
let users = client.get_users(Request::new(())).await.unwrap();
println!("{}", pretty_json(&users.into_inner()).unwrap());
}
Command::GetUser(data) => {
match UserId::from_str(&data.user_id) {
Command::GetUser(user) => {
match UserId::from_str(&user.user_id) {
Ok(user_id) => {
match client
.get_user(Request::new(msgs::GetUserRequest {
user_id: user_id.serialize(),
user_id: user_id.to_vec(),
}))
.await
{
Ok(response) => {
println!("{}", pretty_json(&response.into_inner()).unwrap())
}
Err(status) => println!("{}", status.message()),
Err(status) => handle_error(status.message()),
}
}
Err(e) => println!("{}", e),
Err(e) => handle_error(e),
};
}
Command::Stop => {

View file

@ -4,10 +4,12 @@ use serde::Deserialize;
use structopt::StructOpt;
#[derive(Debug, StructOpt, Clone)]
#[structopt(rename_all = "snake_case")]
#[structopt(rename_all = "lower_case")]
pub enum Command {
/// Gets information about all appointments stored in the tower
GetAllAppointments,
/// Gets information about specific appointments stored in the tower using a locator
GetAppointments(GetAppointmentsData),
/// Gets generic information about the tower, like tower id and aggregate data on users and appointments
GetTowerInfo,
/// Gets an array with the user ids of all the users registered to the tower
@ -19,17 +21,23 @@ pub enum Command {
}
#[derive(Debug, StructOpt, Clone)]
#[structopt(rename_all = "lowercase")]
#[structopt(rename_all = "snake_case")]
pub struct GetUserData {
/// The user identifier (33-byte compressed public key).
pub user_id: String,
}
#[derive(Debug, StructOpt, Clone)]
pub struct GetAppointmentsData {
/// The locator of the appointments (16-byte hexadecimal string).
pub locator: String,
}
/// Holds all the command line options and commands.
#[derive(StructOpt, Debug)]
#[structopt(rename_all = "lowercase")]
#[structopt(
version = "0.0.1",
version = env!("CARGO_PKG_VERSION"),
about = "The Eye of Satoshi - CLI",
name = "teos-cli"
)]
@ -46,10 +54,6 @@ pub struct Opt {
#[structopt(long, default_value = "~/.teos")]
pub data_dir: String,
/// Runs teos-cli in debug mode [default: false]
#[structopt(long)]
pub debug: bool,
/// Command
#[structopt(subcommand)]
pub command: Command,
@ -66,7 +70,6 @@ pub struct Opt {
pub struct Config {
pub rpc_bind: String,
pub rpc_port: u16,
pub debug: bool,
}
impl Config {
@ -78,8 +81,6 @@ impl Config {
if options.rpc_port.is_some() {
self.rpc_port = options.rpc_port.unwrap();
}
self.debug |= options.debug;
}
}
@ -94,7 +95,6 @@ impl Default for Config {
Self {
rpc_bind: "localhost".into(),
rpc_port: 8814,
debug: false,
}
}
}

View file

@ -1,20 +1,26 @@
# API
api_bind = "127.0.0.1"
api_port = 9814
tor_control_port = 9051
onion_hidden_service_port = 9814
tor_support = false
# RPC
rpc_bind = "127.0.0.1"
rpc_port = 8814
# bitcoind
btc_network = "bitcoin"
btc_network = "mainnet"
btc_rpc_user = "CSW"
## Notice only user+password **OR** cookie is allowed as rpc auth, any other combination would be rejected
btc_rpc_password = "NotSatoshi"
btc_rpc_connect = "localhost"
btc_rpc_port = 8442
btc_rpc_cookie = "~/.bitcoin/.cookie"
btc_rpc_port = 8332
# Flags
debug = false
deps_debug = false
overwrite_key = false
# General

View file

@ -1,10 +1,7 @@
//! Logic related to the tower configuration and command line parameter parsing.
use bitcoin::network::constants::Network;
use serde::Deserialize;
use std;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::str::FromStr;
use structopt::StructOpt;
pub fn data_dir_absolute_path(data_dir: String) -> PathBuf {
@ -19,24 +16,13 @@ pub fn data_dir_absolute_path(data_dir: String) -> PathBuf {
}
}
pub fn from_file<T: Default + serde::de::DeserializeOwned>(path: PathBuf) -> T {
match std::fs::read(&path) {
Ok(file_content) => toml::from_slice::<T>(&file_content).map_or_else(
|e| {
println!("Couldn't parse config file: {}", e);
println!("Loading default configuration");
T::default()
},
|config| {
println!("Loading configuration from file");
config
},
),
Err(e) => {
println!("Couldn't read config file: {}", e);
println!("Loading default configuration");
pub fn from_file<T: Default + serde::de::DeserializeOwned>(path: &PathBuf) -> T {
match std::fs::read(path) {
Ok(file_content) => toml::from_slice::<T>(&file_content).unwrap_or_else(|e| {
eprintln!("Couldn't parse config file: {e}");
T::default()
}
}),
Err(_) => T::default(),
}
}
@ -52,10 +38,18 @@ impl std::fmt::Display for ConfigError {
impl std::error::Error for ConfigError {}
#[derive(PartialEq)]
pub enum AuthMethod {
UserPass,
CookieFile,
Multiple,
Invalid,
}
/// Holds all the command line options.
#[derive(StructOpt, Debug, Clone)]
#[structopt(rename_all = "lowercase")]
#[structopt(version = "0.0.1", about = "The Eye of Satoshi - Lightning watchtower")]
#[structopt(version = env!("CARGO_PKG_VERSION"), about = "The Eye of Satoshi - Lightning watchtower")]
pub struct Opt {
/// Address teos HTTP(s) API will bind to [default: localhost]
#[structopt(long)]
@ -73,18 +67,22 @@ pub struct Opt {
#[structopt(long)]
pub rpc_port: Option<u16>,
/// Network bitcoind is connected to. Either bitcoin, testnet, signet or regtest [default: bitcoin]
/// Network bitcoind is connected to. Either mainnet, testnet, signet or regtest [default: mainnet]
#[structopt(long)]
pub btc_network: Option<String>,
/// bitcoind rpcuser [default: user]
/// bitcoind rpcuser
#[structopt(long)]
pub btc_rpc_user: Option<String>,
/// bitcoind rpcpassword [default: passwd]
/// bitcoind rpcpassword
#[structopt(long)]
pub btc_rpc_password: Option<String>,
/// bitcoind rpccookie
#[structopt(long)]
pub btc_rpc_cookie: Option<String>,
/// bitcoind rpcconnect [default: localhost]
#[structopt(long)]
pub btc_rpc_connect: Option<String>,
@ -101,9 +99,30 @@ pub struct Opt {
#[structopt(long)]
pub debug: bool,
/// Runs third party libs in debug mode
#[structopt(long)]
pub deps_debug: bool,
/// Overwrites the tower secret key. THIS IS IRREVERSIBLE AND WILL CHANGE YOUR TOWER ID
#[structopt(long)]
pub overwrite_key: bool,
/// If set, creates a Tor endpoint to serve API data. This endpoint is additional to the clearnet HTTP API
#[structopt(long)]
pub tor_support: bool,
/// Forces the tower to run even if the underlying chain has gone too far out of sync. This can only happen
/// if the node is being run in pruned mode.
#[structopt(long)]
pub force_update: bool,
/// Tor control port [default: 9051]
#[structopt(long)]
pub tor_control_port: Option<u16>,
/// Port for the onion hidden service to listen on [default: 9814]
#[structopt(long)]
pub onion_hidden_service_port: Option<u16>,
}
/// Holds all configuration options.
@ -112,7 +131,7 @@ pub struct Opt {
/// - Defaults
/// - Configuration file
/// - Command line options
#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
#[serde(default)]
pub struct Config {
// API
@ -126,13 +145,16 @@ pub struct Config {
// Bitcoind
pub btc_network: String,
pub btc_rpc_user: String,
pub btc_rpc_cookie: String,
pub btc_rpc_password: String,
pub btc_rpc_connect: String,
pub btc_rpc_port: u16,
// Flags
pub debug: bool,
pub deps_debug: bool,
pub overwrite_key: bool,
pub force_update: bool,
// General
pub subscription_slots: u32,
@ -144,9 +166,32 @@ pub struct Config {
// Internal API
pub internal_api_bind: String,
pub internal_api_port: u32,
// Tor
pub tor_support: bool,
pub tor_control_port: u16,
pub onion_hidden_service_port: u16,
}
impl Config {
/// The only combinations of valid authentication methods are:
/// - User **AND** password
/// - **OR** Cookie file
//
/// Any other combination will be rejected
pub fn get_auth_method(&self) -> AuthMethod {
match (
self.btc_rpc_user.is_empty(),
self.btc_rpc_password.is_empty(),
self.btc_rpc_cookie.is_empty(),
) {
(false, false, true) => AuthMethod::UserPass,
(true, true, false) => AuthMethod::CookieFile,
(true, true, true) => AuthMethod::Invalid,
_ => AuthMethod::Multiple,
}
}
/// Patches the configuration options with the command line options.
pub fn patch_with_options(&mut self, options: Opt) {
if options.api_bind.is_some() {
@ -170,15 +215,27 @@ impl Config {
if options.btc_rpc_password.is_some() {
self.btc_rpc_password = options.btc_rpc_password.unwrap();
}
if options.btc_rpc_cookie.is_some() {
self.btc_rpc_cookie = options.btc_rpc_cookie.unwrap();
}
if options.btc_rpc_connect.is_some() {
self.btc_rpc_connect = options.btc_rpc_connect.unwrap();
}
if options.btc_rpc_port.is_some() {
self.btc_rpc_port = options.btc_rpc_port.unwrap();
}
if options.tor_control_port.is_some() {
self.tor_control_port = options.tor_control_port.unwrap();
}
if options.onion_hidden_service_port.is_some() {
self.onion_hidden_service_port = options.onion_hidden_service_port.unwrap();
}
self.tor_support |= options.tor_support;
self.debug |= options.debug;
self.deps_debug |= options.deps_debug;
self.overwrite_key = options.overwrite_key;
self.force_update = options.force_update;
}
/// Verifies that [Config] is properly built.
@ -190,30 +247,60 @@ impl Config {
/// This will also assign the default `btc_rpc_port` depending on the network if it has not
/// been overwritten at this point.
pub fn verify(&mut self) -> Result<(), ConfigError> {
if self.btc_rpc_user == String::new() {
return Err(ConfigError("btc_rpc_user must be set".to_owned()));
}
if self.btc_rpc_password == String::new() {
return Err(ConfigError("btc_rpc_password must be set".to_owned()));
let auth_method = self.get_auth_method();
if auth_method == AuthMethod::Invalid {
return Err(ConfigError("No valid bitcoind auth provided. Set either both btc_rpc_user/btc_rpc_password or btc_rpc_cookie".to_owned()));
} else if auth_method == AuthMethod::Multiple {
return Err(ConfigError(
"Multiple bitcoind auth provided. Pick a single one (either btc_rpc_user/btc_rpc_password or btc_rpc_cookie)"
.to_owned(),
));
}
match Network::from_str(&self.btc_network) {
Ok(network) => {
// Set the port to it's default (depending on the network) if it has not been
// overwritten at this point.
if self.btc_rpc_port == 0 {
self.btc_rpc_port = match network {
Network::Testnet => 18333,
Network::Signet => 38333,
Network::Regtest => 18443,
_ => 8442,
// Normalize the network option to the ones used by bitcoind.
if ["mainnet", "testnet"].contains(&self.btc_network.as_str()) {
self.btc_network = self.btc_network.trim_end_matches("net").into();
}
let default_rpc_port = match self.btc_network.as_str() {
"main" => 8332,
"test" => 18332,
"regtest" => 18443,
"signet" => 38332,
_ => return Err(ConfigError(format!("btc_network not recognized. Expected {{mainnet, testnet, signet, regtest}}, received {}", self.btc_network)))
};
// Set the port to it's default (depending on the network) if it has not been
// overwritten at this point.
if self.btc_rpc_port == 0 {
self.btc_rpc_port = default_rpc_port;
}
Ok(())
}
/// Checks whether the config has been set with only with default values.
pub fn is_default(&self) -> bool {
self == &Config::default()
}
/// Logs non-default options.
pub fn log_non_default_options(&self) {
let json_default_config = serde_json::json!(&Config::default());
let json_config = serde_json::json!(&self);
let sensitive_args = ["btc_rpc_user", "btc_rpc_password"];
for (key, value) in json_config.as_object().unwrap().iter() {
if *value != json_default_config[key] {
log::info!(
"Custom config arg: {}: {}",
key,
if sensitive_args.contains(&key.as_str()) {
"****".to_owned()
} else {
value.to_string()
}
}
Ok(())
}
Err(_) => {
Err(ConfigError(format!("btc_network not recognized. Expected {{bitcoin, testnet, signet, regtest}}, received {}",
self.btc_network)))
);
}
}
}
@ -230,16 +317,22 @@ impl Default for Config {
Self {
api_bind: "127.0.0.1".into(),
api_port: 9814,
tor_support: false,
tor_control_port: 9051,
onion_hidden_service_port: 9814,
rpc_bind: "127.0.0.1".into(),
rpc_port: 8814,
btc_network: "bitcoin".into(),
btc_network: "mainnet".into(),
btc_rpc_user: String::new(),
btc_rpc_password: String::new(),
btc_rpc_cookie: String::new(),
btc_rpc_connect: "localhost".into(),
btc_rpc_port: 0,
debug: false,
deps_debug: false,
overwrite_key: false,
force_update: false,
subscription_slots: 10000,
subscription_duration: 4320,
expiry_delta: 6,
@ -260,17 +353,23 @@ mod tests {
Self {
api_bind: None,
api_port: None,
tor_support: false,
tor_control_port: None,
onion_hidden_service_port: None,
rpc_bind: None,
rpc_port: None,
btc_network: None,
btc_rpc_user: None,
btc_rpc_password: None,
btc_rpc_cookie: None,
btc_rpc_connect: None,
btc_rpc_port: None,
data_dir: String::from("~/.teos"),
debug: false,
deps_debug: false,
overwrite_key: false,
force_update: false,
}
}
}
@ -290,7 +389,7 @@ mod tests {
assert_eq!(config.api_bind, expected_value);
// Check the rest of fields are equal. The easiest is to just the field back and compare with a clone
config.api_bind = config_clone.api_bind.clone();
config.api_bind.clone_from(&config_clone.api_bind);
assert_eq!(config, config_clone);
}
@ -299,7 +398,9 @@ mod tests {
// Tests that the default configuration does not pass verification checks. This is on purpose so some fields are
// required to be updated by the user.
let mut config = Config::default();
assert!(matches!(config.verify(), Err(ConfigError { .. })));
assert!(
matches!(config.verify(), Err(ConfigError(e)) if e.contains("No valid bitcoind auth provided"))
);
}
#[test]
@ -322,6 +423,20 @@ mod tests {
btc_network: "wrong_network".to_owned(),
..Default::default()
};
assert!(matches!(config.verify(), Err(ConfigError { .. })));
assert!(
matches!(config.verify(), Err(ConfigError(e)) if e.contains("btc_network not recognized"))
);
}
#[test]
fn test_config_verify_tor_set() {
let mut config = Config {
btc_rpc_user: "user".to_owned(),
btc_rpc_password: "password".to_owned(),
tor_support: true,
..Default::default()
};
config.verify().unwrap()
}
}

File diff suppressed because it is too large Load diff

View file

@ -6,11 +6,11 @@ use std::fmt;
use bitcoin::hashes::{ripemd160, Hash};
use crate::protos as msgs;
use teos_common::appointment::{Appointment, Locator};
use teos_common::UserId;
/// Unique identifier used to identify appointments.
#[allow(clippy::upper_case_acronyms)]
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
pub(crate) struct UUID([u8; 20]);
@ -21,18 +21,18 @@ impl UUID {
/// when a user requests it without having to perform lookups based on the [Locator], and match what [UUID] belongs to what user (if any).
/// Therefore, it provides a hard-to-forge id while reducing the tower lookups and the required data to be stored (no reverse maps).
pub fn new(locator: Locator, user_id: UserId) -> Self {
let mut uuid_data = locator.serialize();
uuid_data.extend(&user_id.0.serialize());
UUID(ripemd160::Hash::hash(&uuid_data).into_inner())
let mut uuid_data = locator.to_vec();
uuid_data.extend(user_id.0.serialize());
UUID(ripemd160::Hash::hash(&uuid_data).to_byte_array())
}
/// Serializes the [UUID] returning its byte representation.
pub fn serialize(&self) -> Vec<u8> {
pub fn to_vec(self) -> Vec<u8> {
self.0.to_vec()
}
/// Builds a [UUID] from its byte representation.
pub fn deserialize(data: &[u8]) -> Result<Self, TryFromSliceError> {
pub fn from_slice(data: &[u8]) -> Result<Self, TryFromSliceError> {
data.try_into().map(Self)
}
}
@ -46,8 +46,6 @@ impl std::fmt::Display for UUID {
/// An extended version of the appointment hold by the tower.
///
/// The [Appointment] is extended in terms of data, that is, it provides further information only relevant to the tower.
/// Notice [ExtendedAppointment]s are not kept in memory but persisted on disk. The [Watcher](crate::watcher::Watcher)
/// keeps [AppointmentSummary] instead.
#[derive(Debug, Eq, PartialEq, Clone)]
pub(crate) struct ExtendedAppointment {
/// The underlying appointment extended by [ExtendedAppointment].
@ -60,18 +58,6 @@ pub(crate) struct ExtendedAppointment {
pub start_block: u32,
}
/// A summary of an appointment.
///
/// Contains the minimal amount of data the [Watcher](crate::watcher::Watcher) needs to keep in memory in order to
/// watch for breaches.
#[derive(Debug, Eq, PartialEq, Clone)]
pub(crate) struct AppointmentSummary {
/// The [Appointment] locator.
pub locator: Locator,
/// The user this [Appointment] belongs to.
pub user_id: UserId,
}
impl ExtendedAppointment {
/// Create a new [ExtendedAppointment].
pub fn new(
@ -103,52 +89,23 @@ impl ExtendedAppointment {
self.inner.to_self_delay
}
/// Computes the summary of the [ExtendedAppointment].
pub fn get_summary(&self) -> AppointmentSummary {
AppointmentSummary {
locator: self.locator(),
user_id: self.user_id,
}
pub fn uuid(&self) -> UUID {
UUID::new(self.inner.locator, self.user_id)
}
}
impl From<Appointment> for msgs::Appointment {
fn from(a: Appointment) -> Self {
Self {
locator: a.locator.serialize(),
encrypted_blob: a.encrypted_blob.clone(),
to_self_delay: a.to_self_delay,
}
}
}
/// Computes the number of slots an appointment takes from a user subscription.
///
/// This is based on the [encrypted_blob](Appointment::encrypted_blob) size and the slot size that was defined by the [Gatekeeper](crate::gatekeeper::Gatekeeper).
pub(crate) fn compute_appointment_slots(blob_size: usize, blob_max_size: usize) -> u32 {
(blob_size as f32 / blob_max_size as f32).ceil() as u32
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::get_random_user_id;
use teos_common::appointment::Appointment;
use teos_common::cryptography::get_random_bytes;
use crate::test_utils::generate_uuid;
#[test]
fn test_get_summary() {
let locator = Locator::deserialize(&get_random_bytes(16)).unwrap();
let user_id = get_random_user_id();
let signature = String::new();
let a = Appointment::new(locator, get_random_bytes(32), 42);
let e = ExtendedAppointment::new(a, user_id, signature, 21);
let s = e.get_summary();
assert_eq!(e.locator(), s.locator);
assert_eq!(e.user_id, s.user_id);
fn test_uuid_ser_deser() {
let original_uuid = generate_uuid();
assert_eq!(
UUID::from_slice(&original_uuid.to_vec()).unwrap(),
original_uuid
);
}
}

View file

@ -1,51 +1,37 @@
//! Logic related to the Gatekeeper, the component in charge of managing access to the tower resources.
use std::collections::{HashMap, HashSet};
use std::iter::FromIterator;
use lightning::chain;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use lightning::chain;
use teos_common::appointment::{compute_appointment_slots, Locator};
use teos_common::constants::ENCRYPTED_BLOB_MAX_SIZE;
use teos_common::cryptography;
use teos_common::receipts::RegistrationReceipt;
use teos_common::UserId;
use crate::dbm::DBM;
use crate::extended_appointment::{compute_appointment_slots, ExtendedAppointment, UUID};
use crate::extended_appointment::{ExtendedAppointment, UUID};
/// Data regarding a user subscription with the tower.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct UserInfo {
/// Number of appointment slots available for a given user.
pub(crate) available_slots: u32,
/// Block height where the user subscription will expire.
/// Block height where the user subscription starts.
pub(crate) subscription_start: u32,
/// Block height where the user subscription expires.
pub(crate) subscription_expiry: u32,
/// Map of appointment ids and the how many slots they take from the subscription.
pub(crate) appointments: HashMap<UUID, u32>,
}
impl UserInfo {
/// Creates a new [UserInfo] instance.
pub fn new(available_slots: u32, subscription_expiry: u32) -> Self {
pub fn new(available_slots: u32, subscription_start: u32, subscription_expiry: u32) -> Self {
UserInfo {
available_slots,
subscription_start,
subscription_expiry,
appointments: HashMap::new(),
}
}
/// Creates a new [UserInfo] instance with some associated appointments.
pub fn with_appointments(
available_slots: u32,
subscription_expiry: u32,
appointments: HashMap<UUID, u32>,
) -> Self {
UserInfo {
available_slots,
subscription_expiry,
appointments,
}
}
}
@ -128,8 +114,9 @@ impl Gatekeeper {
}
/// Gets the data held by the tower about a given user.
pub(crate) fn get_user_info(&self, user_id: UserId) -> Option<UserInfo> {
self.registered_users.lock().unwrap().get(&user_id).cloned()
pub(crate) fn get_user_info(&self, user_id: UserId) -> Option<(UserInfo, Vec<Locator>)> {
let info = self.registered_users.lock().unwrap().get(&user_id).cloned();
info.map(|info| (info, self.dbm.lock().unwrap().load_user_locators(user_id)))
}
/// Authenticates a user.
@ -169,7 +156,10 @@ impl Gatekeeper {
.available_slots
.checked_add(self.subscription_slots)
.ok_or(MaxSlotsReached)?;
user_info.subscription_expiry = block_count + self.subscription_duration;
user_info.subscription_expiry = user_info
.subscription_expiry
.checked_add(self.subscription_duration)
.unwrap_or(u32::MAX);
self.dbm.lock().unwrap().update_user(user_id, user_info);
user_info
@ -178,6 +168,7 @@ impl Gatekeeper {
None => {
let user_info = UserInfo::new(
self.subscription_slots,
block_count,
block_count + self.subscription_duration,
);
self.dbm
@ -194,6 +185,7 @@ impl Gatekeeper {
Ok(RegistrationReceipt::new(
user_id,
user_info.available_slots,
user_info.subscription_start,
user_info.subscription_expiry,
))
}
@ -208,7 +200,13 @@ impl Gatekeeper {
// For updates, the difference between the existing appointment size and the update is computed.
let mut registered_users = self.registered_users.lock().unwrap();
let user_info = registered_users.get_mut(&user_id).unwrap();
let used_slots = user_info.appointments.get(&uuid).map_or(0, |x| *x);
let used_blob_size = self
.dbm
.lock()
.unwrap()
.get_appointment_length(uuid)
.unwrap_or(0);
let used_slots = compute_appointment_slots(used_blob_size, ENCRYPTED_BLOB_MAX_SIZE);
let required_slots =
compute_appointment_slots(appointment.encrypted_blob().len(), ENCRYPTED_BLOB_MAX_SIZE);
@ -217,7 +215,6 @@ impl Gatekeeper {
if diff <= user_info.available_slots as i64 {
// Filling / freeing slots depending on whether this is an update or not, and if it is bigger or smaller
// than the old appointment
user_info.appointments.insert(uuid, required_slots);
user_info.available_slots = (user_info.available_slots as i64 - diff) as u32;
self.dbm.lock().unwrap().update_user(user_id, user_info);
@ -247,56 +244,50 @@ impl Gatekeeper {
/// Gets a map of outdated users. Outdated users are those whose subscription has expired and the renewal grace period
/// has already passed ([expiry_delta](Self::expiry_delta)).
pub(crate) fn get_outdated_users(&self, block_height: u32) -> HashMap<UserId, HashSet<UUID>> {
let registered_users = self.registered_users.lock().unwrap().clone();
registered_users
.into_iter()
.filter(|(_, info)| block_height == info.subscription_expiry + self.expiry_delta)
.map(|(id, info)| (id, info.appointments.keys().cloned().collect()))
pub(crate) fn get_outdated_users(&self, block_height: u32) -> Vec<UserId> {
self.registered_users
.lock()
.unwrap()
.iter()
// NOTE: Ideally there won't be a user with `block_height > subscription_expiry + expiry_delta`, but
// this might happen if we skip a couple of block connections due to a force update.
.filter(|(_, info)| block_height >= info.subscription_expiry + self.expiry_delta)
.map(|(user_id, _)| *user_id)
.collect()
}
/// Gets a set of outdated user ids.
pub(crate) fn get_outdated_user_ids(&self, block_height: u32) -> HashSet<UserId> {
self.get_outdated_users(block_height)
.keys()
.cloned()
.collect()
}
/// Get a map of outdated appointments (from any user).
pub(crate) fn get_outdated_appointments(&self, block_height: u32) -> HashSet<UUID> {
HashSet::from_iter(
self.get_outdated_users(block_height)
.into_values()
.flatten(),
)
}
/// Deletes a collection of appointments from the users' subscriptions (from memory only)
/// and updates the available_slots count for the given user.
/// Deletes these appointments from the database and updates the user's information.
///
/// Notice appointments are only de-linked from users, but not actually removed. This is because the [Gatekeeper]
/// does not actually hold any [ExtendedAppointment](crate::extended_appointment::ExtendedAppointment) data,
/// just references to them.
pub(crate) fn delete_appointments_from_memory(
&self,
appointments: &HashMap<UUID, UserId>,
) -> HashMap<UserId, UserInfo> {
let mut updated_users = HashMap::new();
let mut registered_users = self.registered_users.lock().unwrap();
/// If `refund` is set, the appointments owners will get their slots refunded back.
///
/// DISCUSS: When `refund` is `false` we don't give back the slots to the user for the deleted appointments.
/// This is to discourage misbehavior (sending bad appointments, either non-decryptable or rejected by the network).
pub(crate) fn delete_appointments(&self, appointments: Vec<UUID>, refund: bool) {
let mut dbm = self.dbm.lock().unwrap();
for (uuid, user_id) in appointments {
// Remove the appointment from the appointment list and update the available slots
if let Some(user_info) = registered_users.get_mut(user_id) {
if let Some(x) = user_info.appointments.remove(uuid) {
user_info.available_slots += x;
}
updated_users.insert(*user_id, user_info.clone());
};
let updated_users = if refund {
let mut updated_users = HashMap::new();
let mut registered_users = self.registered_users.lock().unwrap();
// Give back the consumed slots to each user.
for uuid in appointments.iter() {
let (user_id, blob_size) = dbm.get_appointment_user_and_length(*uuid).unwrap();
registered_users.get_mut(&user_id).unwrap().available_slots +=
compute_appointment_slots(blob_size, ENCRYPTED_BLOB_MAX_SIZE);
updated_users.insert(user_id, registered_users[&user_id]);
}
updated_users
} else {
// No updated users.
HashMap::new()
};
// An optimization for the case when only one appointment is being deleted without refunding.
// This avoids creating a DB transaction for a single query.
if appointments.len() == 1 && updated_users.is_empty() {
dbm.remove_appointment(appointments[0])
} else {
dbm.batch_remove_appointments(&appointments, &updated_users);
}
updated_users
}
}
@ -304,16 +295,28 @@ impl chain::Listen for Gatekeeper {
/// Handles the monitoring process by the [Gatekeeper].
///
/// This is mainly used to keep track of time and expire / outdate subscriptions when needed.
fn block_connected(&self, block: &bitcoin::Block, height: u32) {
log::info!("New block received: {}", block.block_hash());
fn filtered_block_connected(
&self,
header: &bitcoin::block::Header,
_: &chain::transaction::TransactionData,
height: u32,
) {
log::info!("New block received: {}", header.block_hash());
// Expired user deletion is delayed. Users are deleted when their subscription is outdated, not expired.
let outdated_users = self.get_outdated_user_ids(height);
self.registered_users
.lock()
.unwrap()
.retain(|id, _| !outdated_users.contains(id));
self.dbm.lock().unwrap().batch_remove_users(&outdated_users);
let outdated_users = self.get_outdated_users(height);
if !outdated_users.is_empty() {
// Remove the outdated users from memory first.
{
let mut registered_users = self.registered_users.lock().unwrap();
// Removing each outdated user in a loop is more efficient than retaining non-outdated users
// because retaining would loop over all the available users which is always more than the outdated ones.
for outdated_user in outdated_users.iter() {
registered_users.remove(outdated_user);
}
}
self.dbm.lock().unwrap().batch_remove_users(&outdated_users);
}
// Update last known block height
self.last_known_block_height
@ -321,7 +324,7 @@ impl chain::Listen for Gatekeeper {
}
/// Handles reorgs in the [Gatekeeper]. Simply updates the last_known_block_height.
fn block_disconnected(&self, header: &bitcoin::BlockHeader, height: u32) {
fn block_disconnected(&self, header: &bitcoin::block::Header, height: u32) {
log::warn!("Block disconnected: {}", header.block_hash());
// There's nothing to be done here but updating the last known block
self.last_known_block_height
@ -333,13 +336,12 @@ impl chain::Listen for Gatekeeper {
mod tests {
use super::*;
use crate::dbm::Error as DBError;
use crate::test_utils::{
generate_dummy_appointment, generate_dummy_appointment_with_user, generate_uuid,
get_random_user_id, Blockchain,
};
use crate::test_utils::{generate_dummy_appointment_with_user, get_random_tracker, Blockchain};
use lightning::chain::Listen;
use teos_common::cryptography::{get_random_bytes, get_random_keypair};
use teos_common::test_utils::get_random_user_id;
use crate::responder::ConfirmationStatus;
const SLOTS: u32 = 21;
const DURATION: u32 = 500;
@ -363,21 +365,11 @@ mod tests {
&self.registered_users
}
pub(crate) fn add_outdated_user(
&self,
user_id: UserId,
outdates_at: u32,
appointments: Option<Vec<UUID>>,
) {
pub(crate) fn add_outdated_user(&self, user_id: UserId, outdates_at: u32) {
self.add_update_user(user_id).unwrap();
let mut registered_users = self.registered_users.lock().unwrap();
let mut user = registered_users.get_mut(&user_id).unwrap();
let user = registered_users.get_mut(&user_id).unwrap();
user.subscription_expiry = outdates_at - self.expiry_delta;
if let Some(uuids) = appointments {
for uuid in uuids.iter() {
user.appointments.insert(*uuid, 1);
}
}
}
}
@ -443,7 +435,7 @@ mod tests {
// Let's now provide data generated by an actual user, still the user is unknown
let (user_sk, user_pk) = get_random_keypair();
let signature = cryptography::sign(message, &user_sk).unwrap();
let signature = cryptography::sign(message, &user_sk);
assert_eq!(
gatekeeper.authenticate_user(message, &signature),
Err(AuthenticationFailure("User not found."))
@ -472,7 +464,11 @@ mod tests {
// The data should have been also added to the database
assert_eq!(
gatekeeper.dbm.lock().unwrap().load_user(user_id).unwrap(),
UserInfo::new(receipt.available_slots(), receipt.subscription_expiry())
UserInfo::new(
receipt.available_slots(),
receipt.subscription_start(),
receipt.subscription_expiry()
)
);
// Let generate a new block and add the user again to check that both the slots and expiry are updated.
@ -482,13 +478,10 @@ mod tests {
.store(chain.get_block_count(), Ordering::Relaxed);
let updated_receipt = gatekeeper.add_update_user(user_id).unwrap();
assert_eq!(
updated_receipt.available_slots(),
receipt.available_slots() * 2
);
assert_eq!(updated_receipt.available_slots(), SLOTS * 2);
assert_eq!(
updated_receipt.subscription_expiry(),
receipt.subscription_expiry() + 1
START_HEIGHT as u32 + DURATION * 2
);
// Data in the database should have been updated too
@ -496,6 +489,7 @@ mod tests {
gatekeeper.dbm.lock().unwrap().load_user(user_id).unwrap(),
UserInfo::new(
updated_receipt.available_slots(),
updated_receipt.subscription_start(),
updated_receipt.subscription_expiry()
)
);
@ -519,6 +513,7 @@ mod tests {
gatekeeper.dbm.lock().unwrap().load_user(user_id).unwrap(),
UserInfo::new(
updated_receipt.available_slots(),
updated_receipt.subscription_start(),
updated_receipt.subscription_expiry()
)
);
@ -547,25 +542,32 @@ mod tests {
let available_slots = gatekeeper
.add_update_appointment(user_id, uuid, &appointment)
.unwrap();
// Simulate the watcher adding the appointment in the database.
gatekeeper
.dbm
.lock()
.unwrap()
.store_appointment(uuid, &appointment)
.unwrap();
assert!(gatekeeper.registered_users.lock().unwrap()[&user_id]
.appointments
.contains_key(&uuid));
let (_, user_locators) = gatekeeper.get_user_info(user_id).unwrap();
assert!(user_locators.contains(&appointment.locator()));
assert_eq!(slots_before, available_slots + 1);
// Slots should have been updated in the database too. Notice the appointment won't be there yet
// given the Watcher is responsible for adding it, and it will do so after calling this method
// Slots should have been updated in the database too.
let mut loaded_user = gatekeeper.dbm.lock().unwrap().load_user(user_id).unwrap();
assert_eq!(loaded_user.available_slots, available_slots);
// Adding the exact same appointment should leave the slots count unchanged
// Adding the exact same appointment should leave the slots count unchanged.
// We don't really need to update the appointment in the DB since it's the very same appointment.
let mut updated_slot_count = gatekeeper
.add_update_appointment(user_id, uuid, &appointment)
.unwrap();
assert!(gatekeeper.registered_users.lock().unwrap()[&user_id]
.appointments
.contains_key(&uuid));
let (_, user_locators) = gatekeeper.get_user_info(user_id).unwrap();
assert!(user_locators.contains(&appointment.locator()));
assert_eq!(updated_slot_count, available_slots);
loaded_user = gatekeeper.dbm.lock().unwrap().load_user(user_id).unwrap();
assert_eq!(loaded_user.available_slots, updated_slot_count);
@ -575,10 +577,18 @@ mod tests {
updated_slot_count = gatekeeper
.add_update_appointment(user_id, uuid, &bigger_appointment)
.unwrap();
assert!(gatekeeper.registered_users.lock().unwrap()[&user_id]
.appointments
.contains_key(&uuid));
// Simulate the watcher updating the appointment in the database.
gatekeeper
.dbm
.lock()
.unwrap()
.update_appointment(uuid, &bigger_appointment)
.unwrap();
let (_, user_locators) = gatekeeper.get_user_info(user_id).unwrap();
assert!(user_locators.contains(&appointment.locator()));
assert_eq!(updated_slot_count, available_slots - 1);
loaded_user = gatekeeper.dbm.lock().unwrap().load_user(user_id).unwrap();
assert_eq!(loaded_user.available_slots, updated_slot_count);
@ -586,26 +596,43 @@ mod tests {
updated_slot_count = gatekeeper
.add_update_appointment(user_id, uuid, &appointment)
.unwrap();
assert!(gatekeeper.registered_users.lock().unwrap()[&user_id]
.appointments
.contains_key(&uuid));
// Simulate the watcher updating the appointment in the database.
gatekeeper
.dbm
.lock()
.unwrap()
.update_appointment(uuid, &appointment)
.unwrap();
let (_, user_locators) = gatekeeper.get_user_info(user_id).unwrap();
assert!(user_locators.contains(&appointment.locator()));
assert_eq!(updated_slot_count, available_slots);
loaded_user = gatekeeper.dbm.lock().unwrap().load_user(user_id).unwrap();
assert_eq!(loaded_user.available_slots, updated_slot_count);
// Adding an appointment with a different uuid should not count as an update
let new_uuid = generate_uuid();
let (uuid, appointment) = generate_dummy_appointment_with_user(user_id, None);
updated_slot_count = gatekeeper
.add_update_appointment(user_id, new_uuid, &appointment)
.add_update_appointment(user_id, uuid, &appointment)
.unwrap();
assert!(gatekeeper.registered_users.lock().unwrap()[&user_id]
.appointments
.contains_key(&new_uuid));
// Simulate the watcher adding the appointment in the database.
gatekeeper
.dbm
.lock()
.unwrap()
.store_appointment(uuid, &appointment)
.unwrap();
let (_, user_locators) = gatekeeper.get_user_info(user_id).unwrap();
assert!(user_locators.contains(&appointment.locator()));
assert_eq!(updated_slot_count, available_slots - 1);
loaded_user = gatekeeper.dbm.lock().unwrap().load_user(user_id).unwrap();
assert_eq!(loaded_user.available_slots, updated_slot_count);
// Finally, trying to add an appointment when the user has no enough slots should fail
let (uuid, appointment) = generate_dummy_appointment_with_user(user_id, None);
gatekeeper
.registered_users
.lock()
@ -614,9 +641,10 @@ mod tests {
.unwrap()
.available_slots = 0;
assert!(matches!(
gatekeeper.add_update_appointment(user_id, generate_uuid(), &appointment),
gatekeeper.add_update_appointment(user_id, uuid, &appointment),
Err(NotEnoughSlots)
));
// The entry in the database should remain unchanged in this case
loaded_user = gatekeeper.dbm.lock().unwrap().load_user(user_id).unwrap();
assert_eq!(loaded_user.available_slots, updated_slot_count);
@ -662,148 +690,179 @@ mod tests {
// Initially, there are not outdated users, so querying any block height should return an empty map
for i in 0..start_height {
assert_eq!(gatekeeper.get_outdated_users(i).len(), 0);
assert_eq!(gatekeeper.get_outdated_users(i), vec![]);
}
// Adding a user whose subscription is outdated should return an entry
let user_id = get_random_user_id();
gatekeeper.add_update_user(user_id).unwrap();
// Add also an appointment so we can check the returned data
let appointment = generate_dummy_appointment(None);
let uuid = generate_uuid();
gatekeeper
.add_update_appointment(user_id, uuid, &appointment)
.unwrap();
// Check that data is not yet outdated
assert_eq!(gatekeeper.get_outdated_users(start_height).len(), 0);
assert_eq!(gatekeeper.get_outdated_users(start_height), vec![]);
// Add an outdated user and check again
gatekeeper.add_outdated_user(user_id, start_height, None);
let outdated_users = gatekeeper.get_outdated_users(start_height);
assert_eq!(outdated_users.len(), 1);
assert_eq!(outdated_users[&user_id], HashSet::from_iter([uuid]));
gatekeeper.add_outdated_user(user_id, start_height);
assert_eq!(gatekeeper.get_outdated_users(start_height), vec![user_id]);
}
#[test]
fn test_get_outdated_appointments() {
let start_height = START_HEIGHT as u32 + EXPIRY_DELTA;
let gatekeeper = init_gatekeeper(&Blockchain::default().with_height(start_height as usize));
// get_outdated_appointments returns a list of appointments that were outdated at a given block height, indistinguishably of their user.
// If there are no outdated users, there cannot be outdated appointments
for i in 0..start_height {
assert_eq!(gatekeeper.get_outdated_appointments(i).len(), 0);
}
// Adding data about different users and appointments should return a flattened list of appointments
let user1_id = get_random_user_id();
let user2_id = get_random_user_id();
let uuid1 = generate_uuid();
let uuid2 = generate_uuid();
// Manually set the user expiry for the test
for (user_id, uuid) in [(user1_id, uuid1), (user2_id, uuid2)] {
gatekeeper.add_outdated_user(user_id, start_height, Some(Vec::from_iter([uuid])));
}
let outdated_appointments = gatekeeper.get_outdated_appointments(start_height);
assert_eq!(outdated_appointments.len(), 2);
assert!(outdated_appointments.contains(&uuid1));
assert!(outdated_appointments.contains(&uuid2));
}
#[test]
fn test_delete_appointments_from_memory() {
fn test_delete_appointments_without_refund() {
let gatekeeper = init_gatekeeper(&Blockchain::default().with_height(START_HEIGHT));
let n_users = 100;
let n_apps = 10;
let mut uuids_to_delete = Vec::new();
let mut rest = Vec::new();
let mut trackers = Vec::new();
let mut users_info = HashMap::new();
// delete_appointments will remove a list of appointments from the Gatekeeper (as long as they exist)
let mut all_appointments = HashMap::new();
let mut to_be_deleted = HashMap::new();
let mut rest = HashMap::new();
for i in 1..11 {
for _ in 0..n_users {
let user_id = get_random_user_id();
let uuid = generate_uuid();
all_appointments.insert(uuid, user_id);
if i % 2 == 0 {
to_be_deleted.insert(uuid, user_id);
} else {
rest.insert(uuid, user_id);
}
}
// Calling the method with unknown data should work but do nothing
assert!(gatekeeper.registered_users.lock().unwrap().is_empty());
assert!(gatekeeper
.delete_appointments_from_memory(&all_appointments)
.is_empty());
// If there's matching data in the gatekeeper it should be deleted
for (uuid, user_id) in to_be_deleted.iter() {
gatekeeper.add_update_user(*user_id).unwrap();
gatekeeper
.add_update_appointment(*user_id, *uuid, &generate_dummy_appointment(None))
.unwrap();
}
// Check before deleting
assert_eq!(gatekeeper.registered_users.lock().unwrap().len(), 5);
for (uuid, user_id) in to_be_deleted.iter() {
assert!(gatekeeper.registered_users.lock().unwrap()[user_id]
.appointments
.contains_key(uuid));
// The slot count should be decreased now too (both in memory and in the database)
assert_ne!(
gatekeeper.registered_users.lock().unwrap()[user_id].available_slots,
gatekeeper.subscription_slots
);
assert_ne!(
gatekeeper.add_update_user(user_id).unwrap();
for i in 0..n_apps {
let (uuid, appointment) = generate_dummy_appointment_with_user(user_id, None);
gatekeeper
.add_update_appointment(user_id, uuid, &appointment)
.unwrap();
// Add the appointment to the database. This is normally done by the Watcher.
gatekeeper
.dbm
.lock()
.unwrap()
.load_user(*user_id)
.unwrap()
.available_slots,
gatekeeper.subscription_slots
);
}
for (_, user_id) in rest.iter() {
assert!(!gatekeeper
.registered_users
.lock()
.unwrap()
.contains_key(user_id));
.store_appointment(uuid, &appointment)
.unwrap();
if i % 2 == 0 {
uuids_to_delete.push(uuid);
} else {
rest.push(uuid);
}
// Also trigger some of these appointments as trackers.
if i % 5 == 0 {
gatekeeper
.dbm
.lock()
.unwrap()
.store_tracker(
uuid,
&get_random_tracker(user_id, ConfirmationStatus::ConfirmedIn(42)),
)
.unwrap();
trackers.push(uuid);
}
}
users_info.insert(user_id, gatekeeper.get_user_info(user_id).unwrap().0);
}
// And after
gatekeeper.delete_appointments_from_memory(&all_appointments);
for (uuid, user_id) in to_be_deleted.iter() {
assert!(!gatekeeper.registered_users.lock().unwrap()[user_id]
.appointments
.contains_key(uuid));
// Delete these appointments without refunding their owners.
gatekeeper.delete_appointments(uuids_to_delete.clone(), false);
// The slot count is back to default
assert_eq!(
gatekeeper.registered_users.lock().unwrap()[user_id].available_slots,
gatekeeper.subscription_slots
);
for uuid in uuids_to_delete.clone() {
assert!(!gatekeeper.dbm.lock().unwrap().appointment_exists(uuid));
}
for (_, user_id) in rest.iter() {
assert!(!gatekeeper
.registered_users
.lock()
.unwrap()
.contains_key(user_id));
for uuid in rest {
assert!(gatekeeper.dbm.lock().unwrap().appointment_exists(uuid));
}
for uuid in trackers {
if uuids_to_delete.contains(&uuid) {
// The tracker should be deleted as well.
assert!(!gatekeeper.dbm.lock().unwrap().tracker_exists(uuid));
} else {
assert!(gatekeeper.dbm.lock().unwrap().tracker_exists(uuid));
}
}
for (user_id, user_info_before_deletion) in users_info {
// Since `refund` was false, the users' slots should not have changed after deleting appointments.
let (user_info_after_deletion, _) = gatekeeper.get_user_info(user_id).unwrap();
assert_eq!(user_info_after_deletion, user_info_before_deletion);
}
}
#[test]
fn test_block_connected() {
fn test_delete_appointments_with_refund() {
let gatekeeper = init_gatekeeper(&Blockchain::default().with_height(START_HEIGHT));
let n_users = 100;
let n_apps = 10;
let mut uuids_to_delete = Vec::new();
let mut rest = Vec::new();
let mut trackers = Vec::new();
let mut users_remaining_slots = HashMap::new();
for _ in 0..n_users {
let user_id = get_random_user_id();
gatekeeper.add_update_user(user_id).unwrap();
let mut user_remaining_slots =
gatekeeper.get_user_info(user_id).unwrap().0.available_slots;
for i in 0..n_apps {
let (uuid, appointment) = generate_dummy_appointment_with_user(user_id, None);
gatekeeper
.add_update_appointment(user_id, uuid, &appointment)
.unwrap();
// Add the appointment to the database. This is normally done by the Watcher.
gatekeeper
.dbm
.lock()
.unwrap()
.store_appointment(uuid, &appointment)
.unwrap();
if i % 2 == 0 {
// We don't reduce the remaining slots for the appointments which are
// going to delete since we will refund their owners.
uuids_to_delete.push(uuid);
} else {
rest.push(uuid);
user_remaining_slots -= compute_appointment_slots(
appointment.encrypted_blob().len(),
ENCRYPTED_BLOB_MAX_SIZE,
);
}
// Also trigger some of these appointments as trackers.
if i % 5 == 0 {
gatekeeper
.dbm
.lock()
.unwrap()
.store_tracker(
uuid,
&get_random_tracker(user_id, ConfirmationStatus::ConfirmedIn(42)),
)
.unwrap();
trackers.push(uuid);
}
}
users_remaining_slots.insert(user_id, user_remaining_slots);
}
// Delete these appointments and refund their owners their slots back.
gatekeeper.delete_appointments(uuids_to_delete.clone(), true);
for uuid in uuids_to_delete.clone() {
assert!(!gatekeeper.dbm.lock().unwrap().appointment_exists(uuid));
}
for uuid in rest {
assert!(gatekeeper.dbm.lock().unwrap().appointment_exists(uuid));
}
for uuid in trackers {
if uuids_to_delete.contains(&uuid) {
// The tracker should be deleted as well.
assert!(!gatekeeper.dbm.lock().unwrap().tracker_exists(uuid));
} else {
assert!(gatekeeper.dbm.lock().unwrap().tracker_exists(uuid));
}
}
for (user_id, correct_remaining_slots) in users_remaining_slots {
let remaining_slots_from_db =
gatekeeper.get_user_info(user_id).unwrap().0.available_slots;
assert_eq!(remaining_slots_from_db, correct_remaining_slots);
assert_eq!(
gatekeeper.registered_users.lock().unwrap()[&user_id].available_slots,
correct_remaining_slots
);
}
}
#[test]
fn test_filtered_block_connected() {
// block_connected in the Gatekeeper is used to keep track of time in order to manage the users' subscription expiry.
// Remove users that get outdated at the new block's height from registered_users and the database.
let mut chain = Blockchain::default().with_height(START_HEIGHT);
@ -815,7 +874,7 @@ mod tests {
let user3_id = get_random_user_id();
for user_id in &[user1_id, user2_id, user3_id] {
gatekeeper.add_outdated_user(*user_id, chain.tip().height + 1, None)
gatekeeper.add_outdated_user(*user_id, chain.tip().height + 1)
}
// Connect a new block. Outdated users are deleted
@ -828,10 +887,7 @@ mod tests {
.lock()
.unwrap()
.contains_key(user_id));
assert!(matches!(
gatekeeper.dbm.lock().unwrap().load_user(*user_id),
Err(DBError::NotFound)
));
assert!(gatekeeper.dbm.lock().unwrap().load_user(*user_id).is_none());
}
// Check that the last_known_block_header has been properly updated

View file

@ -2,6 +2,8 @@
//!
//! A watchtower implementation written in Rust.
// FIXME: This is a temporary fix. See https://github.com/tokio-rs/prost/issues/661
#[allow(clippy::derive_partial_eq_without_eq)]
pub mod protos {
tonic::include_proto!("teos.v2");
}
@ -19,6 +21,8 @@ pub mod gatekeeper;
pub mod responder;
#[doc(hidden)]
mod rpc_errors;
pub mod tls;
mod tx_index;
pub mod watcher;
#[cfg(test)]

View file

@ -1,57 +1,59 @@
use simple_logger::init_with_level;
use log::LevelFilter;
use simple_logger::SimpleLogger;
use std::fs;
use std::io::ErrorKind;
use std::ops::{Deref, DerefMut};
use std::str::FromStr;
use std::sync::{Arc, Condvar, Mutex};
use structopt::StructOpt;
use tokio::task;
use tonic::transport::Server;
use tonic::transport::{Certificate, Server, ServerTlsConfig};
use bitcoin::network::constants::Network;
use bitcoin::network::Network;
use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey};
use bitcoincore_rpc::{Auth, Client};
use bitcoincore_rpc::{Auth, Client, RpcApi};
use lightning_block_sync::init::validate_best_block_header;
use lightning_block_sync::poll::{
ChainPoller, Poll, Validate, ValidatedBlock, ValidatedBlockHeader,
};
use lightning_block_sync::{BlockSource, SpvClient, UnboundedCache};
use lightning_block_sync::{BlockSource, BlockSourceError, SpvClient, UnboundedCache};
use teos::api::http;
use teos::api::internal::InternalAPI;
use teos::api::{http, tor::TorAPI};
use teos::bitcoin_cli::BitcoindClient;
use teos::carrier::Carrier;
use teos::chain_monitor::ChainMonitor;
use teos::config::{self, Config, Opt};
use teos::config::{self, AuthMethod, Config, Opt};
use teos::dbm::DBM;
use teos::gatekeeper::Gatekeeper;
use teos::protos as msgs;
use teos::protos::private_tower_services_server::PrivateTowerServicesServer;
use teos::protos::public_tower_services_server::PublicTowerServicesServer;
use teos::responder::Responder;
use teos::tls::tls_init;
use teos::watcher::Watcher;
use teos_common::constants::IRREVOCABLY_RESOLVED;
use teos_common::cryptography::get_random_keypair;
use teos_common::UserId;
use teos_common::TowerId;
async fn get_last_n_blocks<B, T>(
poller: &mut ChainPoller<B, T>,
mut last_known_block: ValidatedBlockHeader,
n: usize,
) -> Vec<ValidatedBlock>
) -> Result<Vec<ValidatedBlock>, BlockSourceError>
where
B: DerefMut<Target = T> + Sized + Send + Sync,
T: BlockSource,
{
let mut last_n_blocks = Vec::new();
let mut last_n_blocks = Vec::with_capacity(n);
for _ in 0..n {
let block = poller.fetch_block(&last_known_block).await.unwrap();
last_known_block = poller
.look_up_previous_header(&last_known_block)
.await
.unwrap();
log::debug!("Fetching block #{}", last_known_block.height);
let block = poller.fetch_block(&last_known_block).await?;
last_known_block = poller.look_up_previous_header(&last_known_block).await?;
last_n_blocks.push(block);
}
last_n_blocks
Ok(last_n_blocks)
}
fn create_new_tower_keypair(db: &DBM) -> (SecretKey, PublicKey) {
@ -64,34 +66,61 @@ fn create_new_tower_keypair(db: &DBM) -> (SecretKey, PublicKey) {
async fn main() {
let opt = Opt::from_args();
let path = config::data_dir_absolute_path(opt.data_dir.clone());
let conf_file_path = path.join("teos.toml");
// Create data dir if it does not exist
fs::create_dir_all(&path).unwrap_or_else(|e| {
eprintln!("Cannot create data dir: {:?}", e);
eprintln!("Cannot create data dir: {e:?}");
std::process::exit(1);
});
// Load conf (from file or defaults) and patch it with the command line parameters received (if any)
let mut conf = config::from_file::<Config>(path.join("teos.toml"));
let mut conf = config::from_file::<Config>(&conf_file_path);
let is_default = conf.is_default();
conf.patch_with_options(opt);
conf.verify().unwrap_or_else(|e| {
eprintln!("{}", e);
eprintln!("{e}");
std::process::exit(1);
});
// Set log level
if conf.debug {
init_with_level(log::Level::Debug).unwrap()
} else {
init_with_level(log::Level::Info).unwrap()
}
SimpleLogger::new()
.with_level(if conf.deps_debug {
LevelFilter::Debug
} else {
LevelFilter::Warn
})
.with_module_level(
"teos",
if conf.debug {
LevelFilter::Debug
} else {
LevelFilter::Info
},
)
.init()
.unwrap();
// Create network dir
let path_network = path.join(conf.btc_network.clone());
fs::create_dir_all(&path_network).unwrap_or_else(|e| {
eprintln!("Cannot create network dir: {:?}", e);
eprintln!("Cannot create network dir: {e:?}");
std::process::exit(1);
});
// Log default data dir
log::info!("Default data directory: {:?}", &path);
// Log datadir path
log::info!("Using data directory: {:?}", &path_network);
// Log config file path based on whether the config file is found or not
if is_default {
log::info!("Config file: {:?} (not found, skipping)", &conf_file_path);
} else {
log::info!("Config file: {:?}", &conf_file_path);
conf.log_non_default_options();
}
let dbm = Arc::new(Mutex::new(
DBM::new(path_network.join("teos_db.sql3")).unwrap(),
));
@ -103,24 +132,30 @@ async fn main() {
if conf.overwrite_key {
log::info!("Overwriting tower keys");
create_new_tower_keypair(&locked_db)
} else if let Some(sk) = locked_db.load_tower_key() {
(sk, PublicKey::from_secret_key(&Secp256k1::new(), &sk))
} else {
match locked_db.load_tower_key() {
Ok(sk) => (sk, PublicKey::from_secret_key(&Secp256k1::new(), &sk)),
Err(_) => {
log::info!("Tower keys not found. Creating a fresh set");
create_new_tower_keypair(&locked_db)
}
}
log::info!("Tower keys not found. Creating a fresh set");
create_new_tower_keypair(&locked_db)
}
};
log::info!("tower_id: {}", tower_pk);
log::info!("tower_id: {tower_pk}");
let btc_rpc_auth = match conf.get_auth_method() {
AuthMethod::CookieFile => {
Auth::CookieFile(config::data_dir_absolute_path(conf.btc_rpc_cookie))
}
AuthMethod::UserPass => Auth::UserPass(conf.btc_rpc_user, conf.btc_rpc_password),
// Notice an invalid conf would have failed on `Config::verify()`
_ => unreachable!("A verified conf will only have one of these two auth methods"),
};
// Initialize our bitcoind client
let (bitcoin_cli, bitcoind_reachable) = match BitcoindClient::new(
&conf.btc_rpc_connect,
conf.btc_rpc_port,
&conf.btc_rpc_user,
&conf.btc_rpc_password,
btc_rpc_auth.clone(),
&conf.btc_network,
)
.await
{
@ -129,8 +164,12 @@ async fn main() {
Arc::new((Mutex::new(true), Condvar::new())),
),
Err(e) => {
log::error!("Failed to connect to bitcoind client: {}", e);
return;
let e_msg = match e.kind() {
ErrorKind::InvalidData => "invalid btcrpcuser or btcrpcpassword".into(),
_ => e.to_string(),
};
log::error!("Failed to connect to bitcoind. Error: {e_msg}");
std::process::exit(1);
}
};
@ -143,27 +182,81 @@ async fn main() {
};
let rpc = Arc::new(
Client::new(
&format!("{}{}:{}", schema, conf.btc_rpc_connect, conf.btc_rpc_port),
Auth::UserPass(conf.btc_rpc_user.clone(), conf.btc_rpc_password.clone()),
&format!("{schema}{}:{}", conf.btc_rpc_connect, conf.btc_rpc_port),
btc_rpc_auth,
)
.unwrap(),
);
let mut derefed = bitcoin_cli.deref();
// Load last known block from DB if found. Poll it from Bitcoind otherwise.
let tip = if let Ok(block_hash) = dbm.lock().unwrap().load_last_known_block() {
derefed
let last_known_block = dbm.lock().unwrap().load_last_known_block();
let tip = if let Some(block_hash) = last_known_block {
let mut last_known_header = derefed
.get_header(&block_hash, None)
.await
.unwrap()
.validate(block_hash)
.unwrap()
} else {
validate_best_block_header(&mut derefed).await.unwrap()
};
log::info!("Last known block: {}", tip.header.block_hash());
.unwrap();
let mut poller = ChainPoller::new(&mut derefed, Network::from_str(&conf.btc_network).unwrap());
let last_n_blocks = get_last_n_blocks(&mut poller, tip, 6).await;
log::info!(
"Last known block: {} (height: {})",
last_known_header.header.block_hash(),
last_known_header.height
);
// If we are running in pruned mode some data may be missing (if we happen to have been offline for a while)
if let Some(prune_height) = rpc.get_blockchain_info().unwrap().prune_height {
if last_known_header.height - IRREVOCABLY_RESOLVED + 1 < prune_height as u32 {
log::warn!(
"Cannot load blocks in the range {}-{}. Chain has gone too far out of sync",
last_known_header.height - IRREVOCABLY_RESOLVED + 1,
last_known_header.height
);
if conf.force_update {
log::info!("Forcing a backend update");
// We want to grab the first IRREVOCABLY_RESOLVED we know about for the initial cache
// So we can perform transitions from there onwards.
let target_height = prune_height + IRREVOCABLY_RESOLVED as u64;
let target_hash = rpc.get_block_hash(target_height).unwrap();
last_known_header = derefed
.get_header(
&rpc.get_block_hash(target_height).unwrap(),
Some(target_height as u32),
)
.await
.unwrap()
.validate(target_hash)
.unwrap();
} else {
log::error!(
"The underlying chain has gone too far out of sync. The tower block cache cannot be initialized. Run with --forceupdate to force update. THIS WILL, POTENTIALLY, MAKE THE TOWER MISS SOME OF ITS APPOINTMENTS"
);
std::process::exit(1);
}
}
}
last_known_header
} else {
validate_best_block_header(&derefed).await.unwrap()
};
// DISCUSS: This is not really required (and only triggered in regtest). This is only in place so the caches can be
// populated with enough blocks mainly because the size of the cache is based on the amount of blocks passed when initializing.
// However, we could add an additional parameter to specify the size of the cache, and initialize with however may blocks we
// could pull from the backend. Adding this functionality just for regtest seemed unnecessary though, hence the check.
if tip.height < IRREVOCABLY_RESOLVED {
log::error!(
"Not enough blocks to start teosd (required: {IRREVOCABLY_RESOLVED}). Mine at least {} more",
IRREVOCABLY_RESOLVED - tip.height
);
std::process::exit(1);
}
log::info!(
"Current chain tip: {} (height: {})",
tip.header.block_hash(),
tip.height
);
// Build components
let gatekeeper = Arc::new(Gatekeeper::new(
@ -174,17 +267,38 @@ async fn main() {
dbm.clone(),
));
let carrier = Carrier::new(rpc, bitcoind_reachable.clone(), tip.deref().height);
let responder = Arc::new(Responder::new(carrier, gatekeeper.clone(), dbm.clone()));
let watcher = Arc::new(Watcher::new(
gatekeeper.clone(),
responder.clone(),
last_n_blocks,
tip.height,
tower_sk,
UserId(tower_pk),
dbm.clone(),
));
let mut poller = ChainPoller::new(
&mut derefed,
Network::from_core_arg(&conf.btc_network).unwrap(),
);
let (responder, watcher) = {
let last_n_blocks = get_last_n_blocks(&mut poller, tip, IRREVOCABLY_RESOLVED as usize)
.await.unwrap_or_else(|e| {
// I'm pretty sure this can only happen if we are pulling blocks from the target to the prune height, and by the time we get to
// the end at least one has been pruned.
log::error!("Couldn't load the latest {IRREVOCABLY_RESOLVED} blocks. Please try again (Error: {})", e.into_inner());
std::process::exit(1);
}
);
let responder = Arc::new(Responder::new(
&last_n_blocks,
tip.height,
Carrier::new(rpc, bitcoind_reachable.clone(), tip.height),
gatekeeper.clone(),
dbm.clone(),
));
let watcher = Arc::new(Watcher::new(
gatekeeper.clone(),
responder.clone(),
&last_n_blocks[0..6],
tip.height,
tower_sk,
TowerId(tower_pk),
dbm.clone(),
));
(responder, watcher)
};
if watcher.is_fresh() & responder.is_fresh() & gatekeeper.is_fresh() {
log::info!("Fresh bootstrap");
@ -193,13 +307,14 @@ async fn main() {
}
let (shutdown_trigger, shutdown_signal_rpc_api) = triggered::trigger();
let shutdown_signal_internal_rpc_api = shutdown_signal_rpc_api.clone();
let shutdown_signal_internal_api = shutdown_signal_rpc_api.clone();
let shutdown_signal_http = shutdown_signal_rpc_api.clone();
let shutdown_signal_cm = shutdown_signal_rpc_api.clone();
let shutdown_signal_tor = shutdown_signal_rpc_api.clone();
// The ordering here actually matters. Listeners are called by order, and we want the gatekeeper to be called
// last, so both the Watcher and the Responder can query the necessary data from it during data deletion.
let listener = &(watcher.clone(), &(responder, gatekeeper));
// first so it updates the users' states and both the Watcher and the Responder operate only on registered users.
let listener = &(gatekeeper, &(watcher.clone(), responder));
let cache = &mut UnboundedCache::new();
let spv_client = SpvClient::new(tip, poller, cache, listener);
let mut chain_monitor = ChainMonitor::new(
@ -217,31 +332,65 @@ async fn main() {
log::info!("Bootstrap completed. Turning on interfaces");
// Build interfaces
let rpc_api = Arc::new(InternalAPI::new(
let http_api_addr = format!("{}:{}", conf.api_bind, conf.api_port)
.parse()
.unwrap();
let mut addresses = vec![msgs::NetworkAddress::from_ipv4(
conf.api_bind.clone(),
conf.api_port,
)];
// Create Tor endpoint if required
let tor_api = if conf.tor_support {
let tor_api = TorAPI::new(
http_api_addr,
conf.onion_hidden_service_port,
conf.tor_control_port,
path_network,
)
.await;
addresses.push(msgs::NetworkAddress::from_torv3(
tor_api.get_onion_address(),
conf.onion_hidden_service_port,
));
Some(tor_api)
} else {
None
};
let internal_api = Arc::new(InternalAPI::new(
watcher,
addresses,
bitcoind_reachable.clone(),
shutdown_trigger,
));
let internal_rpc_api = rpc_api.clone();
let internal_api_cloned = internal_api.clone();
let rpc_api_addr = format!("{}:{}", conf.rpc_bind, conf.rpc_port)
.parse()
.unwrap();
let internal_rpc_api_addr = format!("{}:{}", conf.internal_api_bind, conf.internal_api_port)
.parse()
.unwrap();
let internal_rpc_api_uri = format!(
"http://{}:{}",
conf.internal_api_bind, conf.internal_api_port
);
let http_api_addr = format!("{}:{}", conf.api_bind, conf.api_port)
let internal_api_addr = format!("{}:{}", conf.internal_api_bind, conf.internal_api_port)
.parse()
.unwrap();
// Generate mtls certificates to data directory so the admin can securely connect
// to the server to perform administrative tasks.
let (identity, ca_cert) = tls_init(&path).unwrap_or_else(|e| {
eprintln!("Couldn't generate tls certificates: {e:?}");
std::process::exit(1);
});
let tls = ServerTlsConfig::new()
.identity(identity)
.client_ca_root(Certificate::from_pem(ca_cert));
// Start tasks
let private_api_task = task::spawn(async move {
Server::builder()
.add_service(PrivateTowerServicesServer::new(rpc_api))
.tls_config(tls)
.expect("couldn't configure tls")
.add_service(PrivateTowerServicesServer::new(internal_api))
.serve_with_shutdown(rpc_api_addr, shutdown_signal_rpc_api)
.await
.unwrap();
@ -249,22 +398,50 @@ async fn main() {
let public_api_task = task::spawn(async move {
Server::builder()
.add_service(PublicTowerServicesServer::new(internal_rpc_api))
.serve_with_shutdown(internal_rpc_api_addr, shutdown_signal_internal_rpc_api)
.add_service(PublicTowerServicesServer::new(internal_api_cloned))
.serve_with_shutdown(internal_api_addr, shutdown_signal_internal_api)
.await
.unwrap();
});
let (http_service_ready, ready_signal_http) = triggered::trigger();
let http_api_task = task::spawn(http::serve(
http_api_addr,
internal_rpc_api_uri,
internal_api_addr,
http_service_ready,
shutdown_signal_http,
));
ready_signal_http.await;
// Add Tor Onion Service for public API
let mut tor_task = Option::None;
let (tor_service_ready, ready_signal_tor) = triggered::trigger();
if let Some(tor_api) = tor_api {
log::info!("Starting up Tor hidden service");
tor_task = Some(task::spawn(async move {
if let Err(e) = tor_api
.expose_onion_service(tor_service_ready, shutdown_signal_tor)
.await
{
eprintln!("Cannot connect to the Tor backend: {e}");
std::process::exit(1);
}
}));
ready_signal_tor.await
}
log::info!("Tower ready");
chain_monitor.monitor_chain().await;
// Wait until shutdown
http_api_task.await.unwrap();
private_api_task.await.unwrap();
public_api_task.await.unwrap();
log::info!("Shutting down tower")
if let Some(tor_task) = tor_task {
tor_task.await.unwrap();
}
log::info!("Shutting down tower");
}

File diff suppressed because it is too large Load diff

View file

@ -8,38 +8,39 @@
*/
use rand::Rng;
use std::convert::TryInto;
use std::ops::Deref;
use std::sync::{Arc, Condvar, Mutex};
use std::thread;
use jsonrpc_http_server::jsonrpc_core::error::ErrorCode as JsonRpcErrorCode;
use jsonrpc_http_server::jsonrpc_core::{Error as JsonRpcError, IoHandler, Params, Value};
use jsonrpc_http_server::{Server, ServerBuilder};
use jsonrpc_http_server::{CloseHandle, Server, ServerBuilder};
use bitcoincore_rpc::{Auth, Client as BitcoindClient};
use bitcoin::blockdata::block::{Block, BlockHeader};
use bitcoin::block::Block;
use bitcoin::blockdata::constants::genesis_block;
use bitcoin::blockdata::script::{Builder, Script};
use bitcoin::blockdata::script::{Builder, ScriptBuf};
use bitcoin::blockdata::transaction::{OutPoint, Transaction, TxIn, TxOut};
use bitcoin::hash_types::BlockHash;
use bitcoin::hash_types::Txid;
use bitcoin::hashes::hex::FromHex;
use bitcoin::hashes::Hash;
use bitcoin::network::constants::Network;
use bitcoin::util::hash::bitcoin_merkle_root;
use bitcoin::util::psbt::serialize::Deserialize;
use bitcoin::util::uint::Uint256;
use bitcoin::merkle_tree::calculate_root;
use bitcoin::pow::Work;
use bitcoin::Amount;
use bitcoin::Network;
use bitcoin::Witness;
use lightning_block_sync::poll::{
ChainPoller, Poll, Validate, ValidatedBlock, ValidatedBlockHeader,
};
use lightning_block_sync::{
AsyncBlockSourceResult, BlockHeaderData, BlockSource, BlockSourceError, UnboundedCache,
AsyncBlockSourceResult, BlockData, BlockHeaderData, BlockSource, BlockSourceError,
UnboundedCache,
};
use teos_common::appointment::{Appointment, Locator};
use teos_common::cryptography::{encrypt, get_random_bytes, get_random_keypair};
use teos_common::constants::IRREVOCABLY_RESOLVED;
use teos_common::cryptography::{get_random_bytes, get_random_keypair};
use teos_common::test_utils::{generate_random_appointment, get_random_user_id, TXID_HEX, TX_HEX};
use teos_common::UserId;
use crate::api::internal::InternalAPI;
@ -47,18 +48,20 @@ use crate::carrier::Carrier;
use crate::dbm::DBM;
use crate::extended_appointment::{ExtendedAppointment, UUID};
use crate::gatekeeper::{Gatekeeper, UserInfo};
use crate::protos as msgs;
use crate::responder::{ConfirmationStatus, Responder, TransactionTracker};
use crate::rpc_errors;
use crate::watcher::{Breach, Watcher};
pub(crate) static TX_HEX: &str = "010000000001010000000000000000000000000000000000000000000000000000000000000000ffffffff54038e830a1b4d696e656420627920416e74506f6f6c373432c2005b005e7a0ae3fabe6d6d7841cd582ead8ea5dd8e3de1173cae6fcd2a53c7362ebb7fb6f815604fe07cbe0200000000000000ac0e060005f90000ffffffff04d9476026000000001976a91411dbe48cc6b617f9c6adaf4d9ed5f625b1c7cb5988ac0000000000000000266a24aa21a9ed7248c6efddd8d99bfddd7f499f0b915bffa8253003cc934df1ff14a81301e2340000000000000000266a24b9e11b6d7054937e13f39529d6ad7e685e9dd4efa426f247d5f5a5bed58cdddb2d0fa60100000000000000002b6a2952534b424c4f434b3a054a68aa5368740e8b3e3c67bce45619c2cfd07d4d4f0936a5612d2d0034fa0a0120000000000000000000000000000000000000000000000000000000000000000000000000";
pub(crate) static TXID_HEX: &str =
"338bda693c4a26e0d41a01f7f2887aaf48bf0bdf93e6415c9110b29349349d3e";
pub(crate) const SLOTS: u32 = 21;
pub(crate) const DURATION: u32 = 500;
pub(crate) const EXPIRY_DELTA: u32 = 42;
pub(crate) const START_HEIGHT: usize = 100;
pub(crate) const AVAILABLE_SLOTS: u32 = 21;
pub(crate) const SUBSCRIPTION_START: u32 = START_HEIGHT as u32;
pub(crate) const SUBSCRIPTION_EXPIRY: u32 = SUBSCRIPTION_START + 42;
#[derive(Clone, Default, Debug)]
pub(crate) struct Blockchain {
pub blocks: Vec<Block>,
@ -84,23 +87,10 @@ impl Blockchain {
pub fn with_height(mut self, height: usize) -> Self {
self.blocks.reserve_exact(height);
let bits = BlockHeader::compact_target_from_u256(&Uint256::from_be_bytes([0xff; 32]));
for i in 1..=height {
let prev_block = &self.blocks[i - 1];
let prev_blockhash = prev_block.block_hash();
let time = prev_block.header.time + height as u32;
self.blocks.push(Block {
header: BlockHeader {
version: 0,
prev_blockhash,
merkle_root: Default::default(),
time,
bits,
nonce: 0,
},
txdata: vec![],
});
for _ in 1..=height {
self.generate(None);
}
self
}
@ -166,8 +156,11 @@ impl Blockchain {
fn at_height_unvalidated(&self, height: usize) -> BlockHeaderData {
assert!(!self.blocks.is_empty());
assert!(height < self.blocks.len());
let height_bytes = height.to_be_bytes();
let mut padded_bytes = [0u8; 32];
padded_bytes[32 - height_bytes.len()..].copy_from_slice(&height_bytes);
BlockHeaderData {
chainwork: self.blocks[0].header.work() + Uint256::from_u64(height as u64).unwrap(),
chainwork: self.blocks[0].header.work() + Work::from_be_bytes(padded_bytes),
height: height as u32,
header: self.blocks[height].header,
}
@ -197,29 +190,36 @@ impl Blockchain {
}
pub fn generate(&mut self, txs: Option<Vec<Transaction>>) -> Block {
let bits = BlockHeader::compact_target_from_u256(&Uint256::from_be_bytes([0xff; 32]));
let bits = bitcoin::Target::from_be_bytes([0xff; 32]).to_compact_lossy();
let prev_block = self.blocks.last().unwrap();
let prev_blockhash = prev_block.block_hash();
let time = prev_block.header.time + (self.blocks.len() + 1) as u32;
let txdata = match txs {
Some(t) => t,
None => vec![],
Some(v) => {
if v.is_empty() {
vec![get_random_tx()]
} else {
v
}
}
None => vec![get_random_tx()],
};
let hashes = txdata.iter().map(|obj| obj.txid().as_hash());
let block = Block {
header: BlockHeader {
version: 0,
prev_blockhash,
merkle_root: bitcoin_merkle_root(hashes).into(),
time,
bits,
nonce: 0,
},
txdata,
let hashes = txdata.iter().map(|tx| tx.compute_txid().to_raw_hash());
let mut header = bitcoin::block::Header {
version: bitcoin::block::Version::from_consensus(0),
prev_blockhash,
merkle_root: calculate_root(hashes).unwrap().into(),
time,
bits,
nonce: 0,
};
while header.validate_pow(header.target()).is_err() {
header.nonce += 1;
}
let block = Block { header, txdata };
self.blocks.push(block.clone());
block
@ -228,7 +228,7 @@ impl Blockchain {
impl BlockSource for Blockchain {
fn get_header<'a>(
&'a mut self,
&'a self,
header_hash: &'a BlockHash,
_height_hint: Option<u32>,
) -> AsyncBlockSourceResult<'a, BlockHeaderData> {
@ -251,10 +251,7 @@ impl BlockSource for Blockchain {
})
}
fn get_block<'a>(
&'a mut self,
header_hash: &'a BlockHash,
) -> AsyncBlockSourceResult<'a, Block> {
fn get_block<'a>(&'a self, header_hash: &'a BlockHash) -> AsyncBlockSourceResult<BlockData> {
Box::pin(async move {
for (height, block) in self.blocks.iter().enumerate() {
if block.header.block_hash() == *header_hash {
@ -263,15 +260,14 @@ impl BlockSource for Blockchain {
return Err(BlockSourceError::persistent("block not found"));
}
}
return Ok(block.clone());
return Ok(BlockData::FullBlock(block.clone()));
}
}
Err(BlockSourceError::transient("block not found"))
})
}
fn get_best_block(&mut self) -> AsyncBlockSourceResult<(BlockHash, Option<u32>)> {
fn get_best_block(&self) -> AsyncBlockSourceResult<(BlockHash, Option<u32>)> {
Box::pin(async move {
if *self.unreachable.lock().unwrap() {
return Err(BlockSourceError::transient("Connection refused"));
@ -290,13 +286,7 @@ impl BlockSource for Blockchain {
pub(crate) fn generate_uuid() -> UUID {
let mut rng = rand::thread_rng();
UUID::deserialize(&rng.gen::<[u8; 20]>()).unwrap()
}
pub(crate) fn get_random_user_id() -> UserId {
let (_, pk) = get_random_keypair();
UserId(pk)
UUID::from_slice(&rng.gen::<[u8; 20]>()).unwrap()
}
pub(crate) fn get_random_tx() -> Transaction {
@ -304,42 +294,26 @@ pub(crate) fn get_random_tx() -> Transaction {
let prev_txid_bytes = get_random_bytes(32);
Transaction {
version: 2,
lock_time: 0,
version: bitcoin::transaction::Version(2),
lock_time: bitcoin::locktime::absolute::LockTime::from_height(0).unwrap(),
input: vec![TxIn {
previous_output: OutPoint::new(
Txid::from_slice(&prev_txid_bytes).unwrap(),
rng.gen_range(0..200),
),
script_sig: Script::new(),
witness: Vec::new(),
sequence: 0,
script_sig: ScriptBuf::new(),
witness: Witness::new(),
sequence: bitcoin::Sequence(0),
}],
output: vec![TxOut {
script_pubkey: Builder::new().push_int(1).into_script(),
value: rng.gen_range(0..21000000000),
value: Amount::from_sat(rng.gen_range(0..21_000_000_000)),
}],
}
}
pub(crate) fn generate_dummy_appointment(dispute_txid: Option<&Txid>) -> ExtendedAppointment {
let dispute_txid = match dispute_txid {
Some(l) => *l,
None => {
let prev_txid_bytes = get_random_bytes(32);
Txid::from_slice(&prev_txid_bytes).unwrap()
}
};
let tx_bytes = Vec::from_hex(TX_HEX).unwrap();
let penalty_tx = Transaction::deserialize(&tx_bytes).unwrap();
let mut raw_locator: [u8; 16] = get_random_bytes(16).try_into().unwrap();
raw_locator.copy_from_slice(&dispute_txid[..16]);
let locator = Locator::deserialize(&raw_locator).unwrap();
let encrypted_blob = encrypt(&penalty_tx, &dispute_txid).unwrap();
let appointment = Appointment::new(locator, encrypted_blob, 21);
let appointment = generate_random_appointment(dispute_txid);
let user_id = get_random_user_id();
let user_signature = String::new();
let start_block = 42;
@ -354,7 +328,7 @@ pub(crate) fn generate_dummy_appointment_with_user(
let mut app = generate_dummy_appointment(dispute_txid);
app.user_id = user_id;
(UUID::new(app.locator(), user_id), app)
(app.uuid(), app)
}
pub(crate) fn get_random_breach() -> Breach {
@ -372,62 +346,82 @@ pub(crate) fn get_random_tracker(
TransactionTracker::new(breach, user_id, status)
}
pub(crate) fn store_appointment_and_fks_to_db(
dbm: &DBM,
uuid: UUID,
appointment: &ExtendedAppointment,
) {
dbm.store_user(appointment.user_id, &UserInfo::new(21, 42))
pub(crate) fn store_appointment_and_its_user(dbm: &DBM, appointment: &ExtendedAppointment) {
dbm.store_user(
appointment.user_id,
&UserInfo::new(AVAILABLE_SLOTS, SUBSCRIPTION_START, SUBSCRIPTION_EXPIRY),
)
// It's ok if the user is already stored.
.ok();
dbm.store_appointment(appointment.uuid(), appointment)
.unwrap();
dbm.store_appointment(uuid, appointment).unwrap();
}
pub(crate) async fn get_last_n_blocks(chain: &mut Blockchain, n: usize) -> Vec<ValidatedBlock> {
let tip = chain.tip();
let mut poller = ChainPoller::new(chain, Network::Bitcoin);
let mut last_n_blocks = Vec::with_capacity(n);
let mut last_known_block = Ok(chain.tip());
let poller = ChainPoller::new(chain, Network::Regtest);
let mut last_n_blocks = Vec::new();
let mut last_known_block = tip;
for _ in 0..n {
let block = poller.fetch_block(&last_known_block).await.unwrap();
last_known_block = poller
.look_up_previous_header(&last_known_block)
.await
.unwrap();
let header = last_known_block.unwrap();
let block = poller.fetch_block(&header).await.unwrap();
last_n_blocks.push(block);
last_known_block = poller.look_up_previous_header(&header).await;
}
last_n_blocks
}
pub(crate) fn get_full_blocks(last_n_blocks: &[ValidatedBlock]) -> Vec<Block> {
last_n_blocks.iter().map(get_full_block).collect()
}
pub(crate) fn get_full_block(block: &ValidatedBlock) -> Block {
match block.deref() {
BlockData::FullBlock(b) => b.clone(),
_ => panic!("Expected FullBlock"),
}
}
pub(crate) enum MockedServerQuery {
Regular,
InMempoool,
Error(i64),
}
pub(crate) fn create_carrier(query: MockedServerQuery, height: u32) -> Carrier {
pub(crate) fn create_carrier(query: MockedServerQuery, height: u32) -> (Carrier, BitcoindStopper) {
let bitcoind_mock = match query {
MockedServerQuery::Regular => BitcoindMock::new(MockOptions::empty()),
MockedServerQuery::Regular => BitcoindMock::new(MockOptions::default()),
MockedServerQuery::InMempoool => BitcoindMock::new(MockOptions::in_mempool()),
MockedServerQuery::Error(x) => BitcoindMock::new(MockOptions::with_error(x)),
};
let bitcoin_cli = Arc::new(BitcoindClient::new(bitcoind_mock.url(), Auth::None).unwrap());
let bitcoind_reachable = Arc::new((Mutex::new(true), Condvar::new()));
start_server(bitcoind_mock);
start_server(bitcoind_mock.server);
Carrier::new(bitcoin_cli, bitcoind_reachable, height)
(
Carrier::new(bitcoin_cli, bitcoind_reachable, height),
bitcoind_mock.stopper,
)
}
pub(crate) fn create_responder(
tip: ValidatedBlockHeader,
pub(crate) async fn create_responder(
chain: &mut Blockchain,
gatekeeper: Arc<Gatekeeper>,
dbm: Arc<Mutex<DBM>>,
server_url: &str,
) -> Responder {
let height = chain.tip().height;
// For the local TxIndex logic to be sound, our index needs to have, at least, IRREVOCABLY_RESOLVED blocks
debug_assert!(height >= IRREVOCABLY_RESOLVED);
let last_n_blocks = get_last_n_blocks(chain, IRREVOCABLY_RESOLVED as usize).await;
let bitcoin_cli = Arc::new(BitcoindClient::new(server_url, Auth::None).unwrap());
let bitcoind_reachable = Arc::new((Mutex::new(true), Condvar::new()));
let carrier = Carrier::new(bitcoin_cli, bitcoind_reachable, tip.deref().height);
let carrier = Carrier::new(bitcoin_cli, bitcoind_reachable, height);
Responder::new(carrier, gatekeeper, dbm)
Responder::new(last_n_blocks.as_slice(), height, carrier, gatekeeper, dbm)
}
pub(crate) async fn create_watcher(
@ -436,20 +430,23 @@ pub(crate) async fn create_watcher(
gatekeeper: Arc<Gatekeeper>,
bitcoind_mock: BitcoindMock,
dbm: Arc<Mutex<DBM>>,
) -> Watcher {
) -> (Watcher, BitcoindStopper) {
let last_n_blocks = get_last_n_blocks(chain, 6).await;
start_server(bitcoind_mock);
start_server(bitcoind_mock.server);
let (tower_sk, tower_pk) = get_random_keypair();
let tower_id = UserId(tower_pk);
Watcher::new(
gatekeeper,
responder,
last_n_blocks,
chain.get_block_count(),
tower_sk,
tower_id,
dbm,
(
Watcher::new(
gatekeeper,
responder,
last_n_blocks.as_slice(),
chain.get_block_count(),
tower_sk,
tower_id,
dbm,
),
bitcoind_mock.stopper,
)
}
#[derive(Clone)]
@ -484,8 +481,10 @@ impl Default for ApiConfig {
}
}
pub(crate) async fn create_api_with_config(api_config: ApiConfig) -> Arc<InternalAPI> {
let bitcoind_mock = BitcoindMock::new(MockOptions::empty());
pub(crate) async fn create_api_with_config(
api_config: ApiConfig,
) -> (Arc<InternalAPI>, BitcoindStopper) {
let bitcoind_mock = BitcoindMock::new(MockOptions::default());
let mut chain = Blockchain::default().with_height(START_HEIGHT);
let dbm = Arc::new(Mutex::new(DBM::in_memory().unwrap()));
@ -496,8 +495,9 @@ pub(crate) async fn create_api_with_config(api_config: ApiConfig) -> Arc<Interna
EXPIRY_DELTA,
dbm.clone(),
));
let responder = create_responder(chain.tip(), gk.clone(), dbm.clone(), bitcoind_mock.url());
let watcher = create_watcher(
let responder =
create_responder(&mut chain, gk.clone(), dbm.clone(), bitcoind_mock.url()).await;
let (watcher, stopper) = create_watcher(
&mut chain,
Arc::new(responder),
gk.clone(),
@ -508,58 +508,66 @@ pub(crate) async fn create_api_with_config(api_config: ApiConfig) -> Arc<Interna
let bitcoind_reachable = Arc::new((Mutex::new(api_config.bitcoind_reachable), Condvar::new()));
let (shutdown_trigger, _) = triggered::trigger();
Arc::new(InternalAPI::new(
Arc::new(watcher),
bitcoind_reachable,
shutdown_trigger,
))
(
Arc::new(InternalAPI::new(
Arc::new(watcher),
vec![msgs::NetworkAddress::from_ipv4("address".to_string(), 21)],
bitcoind_reachable,
shutdown_trigger,
)),
stopper,
)
}
pub(crate) async fn create_api() -> Arc<InternalAPI> {
pub(crate) async fn create_api() -> (Arc<InternalAPI>, BitcoindStopper) {
create_api_with_config(ApiConfig::default()).await
}
#[derive(Clone)]
pub struct BitcoindStopper {
close_handle: CloseHandle,
}
impl BitcoindStopper {
pub fn new(close_handle: CloseHandle) -> Self {
Self { close_handle }
}
pub fn close_handle(&self) -> CloseHandle {
self.close_handle.clone()
}
}
impl Drop for BitcoindStopper {
fn drop(&mut self) {
self.close_handle().close()
}
}
pub(crate) struct BitcoindMock {
pub url: String,
pub server: Server,
stopper: BitcoindStopper,
}
#[derive(Default)]
pub(crate) struct MockOptions {
error_code: Option<i64>,
block_hash: Option<BlockHash>,
height: Option<usize>,
in_mempool: bool,
}
impl MockOptions {
pub fn new(error_code: i64, block_hash: BlockHash, height: usize) -> Self {
Self {
error_code: Some(error_code),
block_hash: Some(block_hash),
height: Some(height),
}
}
pub fn empty() -> Self {
Self {
error_code: None,
block_hash: None,
height: None,
}
}
pub fn with_error(error_code: i64) -> Self {
Self {
error_code: Some(error_code),
block_hash: None,
height: None,
in_mempool: false,
}
}
#[allow(dead_code)]
pub fn with_block(block_hash: BlockHash, height: usize) -> Self {
pub fn in_mempool() -> Self {
Self {
error_code: None,
block_hash: Some(block_hash),
height: Some(height),
in_mempool: true,
}
}
}
@ -573,15 +581,10 @@ impl BitcoindMock {
Err(JsonRpcError::new(JsonRpcErrorCode::ServerError(error)))
});
io.add_alias("sendrawtransaction", "error");
io.add_alias("getrawtransaction", "error");
} else {
BitcoindMock::add_sendrawtransaction(&mut io);
}
if let Some(block_hash) = options.block_hash {
BitcoindMock::add_getrawtransaction(&mut io, block_hash.to_string());
if let Some(height) = options.height {
BitcoindMock::add_getblockheader(&mut io, block_hash.to_string(), height);
}
BitcoindMock::add_getrawtransaction(&mut io, options.in_mempool);
}
let server = ServerBuilder::new(io)
@ -591,6 +594,7 @@ impl BitcoindMock {
Self {
url: format!("http://{}", server.address()),
stopper: BitcoindStopper::new(server.close_handle()),
server,
}
}
@ -601,41 +605,25 @@ impl BitcoindMock {
});
}
fn add_getrawtransaction(io: &mut IoHandler, block_hash: String) {
fn add_getrawtransaction(io: &mut IoHandler, in_mempool: bool) {
io.add_sync_method("getrawtransaction", move |_params: Params| {
match _params {
Params::Array(x) => match x[1] {
Value::Bool(x) => {
if x {
Ok(serde_json::json!({"hex": TX_HEX, "txid": TXID_HEX, "hash": TXID_HEX, "size": 0,
"vsize": 0, "version": 1, "locktime": 0, "vin": [], "vout": [], "blockhash": block_hash }))
} else {
Ok(Value::String(TX_HEX.to_owned()))
if !in_mempool {
Err(JsonRpcError::new(JsonRpcErrorCode::ServerError(rpc_errors::RPC_INVALID_ADDRESS_OR_KEY as i64)))
} else {
match _params {
Params::Array(x) => match x[1] {
Value::Bool(x) => {
if x {
Ok(serde_json::json!({"hex": TX_HEX, "txid": TXID_HEX, "hash": TXID_HEX, "size": 0,
"vsize": 0, "version": 1, "locktime": 0, "vin": [], "vout": [] }))
} else {
Ok(Value::String(TX_HEX.to_owned()))
}
}
}
_ => panic!("Boolean param not found"),
},
_ => panic!("No params found"),
}
})
}
fn add_getblockheader(io: &mut IoHandler, block_hash: String, height: usize) {
io.add_sync_method("getblockheader", move |_params: Params| {
match _params {
Params::Array(x) => match x[1] {
Value::Bool(x) => {
if x {
Ok(serde_json::json!({"hash": block_hash, "confirmations": 1, "height": height, "version": 1,
"merkleroot": "4eca41cf0fa551346842eb317564a403e39553444790a65f949f95bc18d24643", "time": 1645719068, "nonce": 2, "bits": "207fffff",
"difficulty": 0.0, "chainwork": "0000000000000000000000000000000000000000000000000000000000001146", "nTx": 1}))
} else {
Ok(Value::String(TX_HEX.to_owned()))
}
}
_ => panic!("Boolean param not found"),
},
_ => panic!("No params found"),
_ => panic!("Boolean param not found"),
},
_ => panic!("No params found"),
}
}
})
}
@ -645,8 +633,8 @@ impl BitcoindMock {
}
}
pub(crate) fn start_server(bitcoind: BitcoindMock) {
pub(crate) fn start_server(server: Server) {
thread::spawn(move || {
bitcoind.server.wait();
server.wait();
});
}

105
teos/src/tls.rs Normal file
View file

@ -0,0 +1,105 @@
/* The following code for generating mTLS certificates is adapted from:
* https://github.com/ElementsProject/lightning/blob/master/plugins/grpc-plugin/src/tls.rs
*
* This file is licensed under the BSD-MIT license, as described here:
* https://github.com/ElementsProject/lightning/blob/master/LICENSE
*/
use rcgen::{Certificate, Error as RcgenError, KeyPair};
use std::convert::TryFrom;
use std::path::Path;
/// Packs the reasons why generating mtls certificates may fail.
#[derive(Debug)]
pub enum GenCertificateFailure {
RcgenError(RcgenError),
IoError(std::io::Error),
}
impl From<RcgenError> for GenCertificateFailure {
fn from(e: RcgenError) -> Self {
GenCertificateFailure::RcgenError(e)
}
}
impl From<std::io::Error> for GenCertificateFailure {
fn from(e: std::io::Error) -> Self {
GenCertificateFailure::IoError(e)
}
}
/// Just a wrapper around a certificate and an associated keypair.
#[derive(Clone, Debug)]
struct Identity {
pub key: Vec<u8>,
pub certificate: Vec<u8>,
}
impl TryFrom<&Identity> for (Certificate, KeyPair) {
type Error = RcgenError;
fn try_from(id: &Identity) -> Result<(Certificate, KeyPair), RcgenError> {
let key = KeyPair::from_pem(&String::from_utf8_lossy(&id.key))?;
let params =
rcgen::CertificateParams::from_ca_cert_pem(&String::from_utf8_lossy(&id.certificate))?;
let cert = params.self_signed(&key)?;
Ok((cert, key))
}
}
pub fn tls_init(
directory: &Path,
) -> Result<(tonic::transport::Identity, Vec<u8>), GenCertificateFailure> {
let ca = generate_or_load_identity("teos Root CA", directory, "ca", None)?;
let server = generate_or_load_identity("teos grpc Server", directory, "server", Some(&ca))?;
let _client = generate_or_load_identity("teos grpc Client", directory, "client", Some(&ca))?;
let server_id = tonic::transport::Identity::from_pem(&server.certificate, &server.key);
Ok((server_id, ca.certificate))
}
/// Generate a given identity
fn generate_or_load_identity(
name: &str,
directory: &Path,
filename: &str,
parent: Option<&Identity>,
) -> Result<Identity, GenCertificateFailure> {
// Just our naming convention here.
let cert_path = directory.join(format!("{filename}.pem"));
let key_path = directory.join(format!("{filename}-key.pem"));
// Did we have to generate a new key? In that case we also need to regenerate the certificate.
if !key_path.exists() || !cert_path.exists() {
log::debug!("Generating a new keypair in {key_path:?}, it didn't exist",);
let keypair = KeyPair::generate()?;
std::fs::write(&key_path, keypair.serialize_pem())?;
log::debug!("Generating a new certificate for key {key_path:?} at {cert_path:?}",);
// Configure the certificate we want.
let subject_alt_names = vec!["teos".to_string(), "localhost".to_string()];
let mut params = rcgen::CertificateParams::new(subject_alt_names)?;
if parent.is_none() {
params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
} else {
params.is_ca = rcgen::IsCa::NoCa;
}
params
.distinguished_name
.push(rcgen::DnType::CommonName, name);
std::fs::write(
&cert_path,
match parent {
None => params.self_signed(&keypair)?.pem(),
Some(ca) => {
let (ca_cert, ca_key) = <(Certificate, KeyPair)>::try_from(ca)?;
params.signed_by(&keypair, &ca_cert, &ca_key)?.pem()
}
},
)?;
}
let key = std::fs::read(&key_path)?;
let certificate = std::fs::read(cert_path)?;
Ok(Identity { certificate, key })
}

418
teos/src/tx_index.rs Normal file
View file

@ -0,0 +1,418 @@
use std::collections::{HashMap, VecDeque};
use std::fmt;
use std::hash::Hash;
use std::ops::Deref;
use bitcoin::block::Header;
use bitcoin::hash_types::BlockHash;
use bitcoin::{Transaction, Txid};
use lightning_block_sync::poll::ValidatedBlock;
use teos_common::appointment::Locator;
/// A trait implemented by types that can be used as key in a [TxIndex].
pub trait Key: Hash + Eq {
fn from_txid(txid: Txid) -> Self;
}
impl Key for Txid {
fn from_txid(txid: Txid) -> Self {
txid
}
}
impl Key for Locator {
fn from_txid(txid: Txid) -> Self {
Locator::new(txid)
}
}
pub enum Type {
Transaction,
BlockHash,
}
pub enum Data {
Transaction(Transaction),
BlockHash(BlockHash),
}
impl fmt::Display for Data {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Data::Transaction(_) => write!(f, "Transaction"),
Data::BlockHash(_) => write!(f, "BlockHash"),
}
}
}
/// A trait implemented by types that can be used as value in a [TxIndex].
pub trait Value {
fn get_type() -> Type;
fn from_data(d: Data) -> Self;
}
impl Value for BlockHash {
fn get_type() -> Type {
Type::BlockHash
}
fn from_data(d: Data) -> Self {
match d {
Data::BlockHash(b) => b,
other => panic!("Cannot build a BlockHash from {}", other),
}
}
}
impl Value for Transaction {
fn get_type() -> Type {
Type::Transaction
}
fn from_data(d: Data) -> Self {
match d {
Data::Transaction(t) => t,
other => panic!("Cannot build a BlockHash from {}", other),
}
}
}
/// Data structure used to index locators computed from parsed blocks.
///
/// Holds up to `size` blocks with their corresponding computed [Locator]s.
#[derive(Debug, PartialEq, Eq)]
pub struct TxIndex<K: Key, V: Value> {
/// A [K]:[V] map.
index: HashMap<K, V>,
/// Vector of block hashes covered by the index.
blocks: VecDeque<BlockHash>,
/// Map of [BlockHash]:[Vec<K>]. Used to remove data from the index.
tx_in_block: HashMap<BlockHash, Vec<K>>,
/// The height of the last block included in the index.
tip: u32,
/// Maximum size of the index.
size: usize,
}
impl<K, V> TxIndex<K, V>
where
K: Key + Copy,
V: Value + Clone,
Self: Sized,
{
pub fn new(last_n_blocks: &[ValidatedBlock], height: u32) -> Self {
let size = last_n_blocks.len();
let mut tx_index = Self {
index: HashMap::new(),
blocks: VecDeque::with_capacity(size),
tx_in_block: HashMap::new(),
tip: height,
size,
};
for block in last_n_blocks.iter().rev() {
match block.deref() {
lightning_block_sync::BlockData::HeaderOnly(_) => {
panic!("Expected FullBlock")
}
lightning_block_sync::BlockData::FullBlock(block) => {
if let Some(prev_block_hash) = tx_index.blocks.back() {
if block.header.prev_blockhash != *prev_block_hash {
panic!("last_n_blocks contains unchained blocks");
}
};
let map = block
.txdata
.iter()
.map(|tx| {
(
K::from_txid(tx.compute_txid()),
match V::get_type() {
Type::Transaction => {
V::from_data(Data::Transaction(tx.clone()))
}
Type::BlockHash => {
V::from_data(Data::BlockHash(block.header.block_hash()))
}
},
)
})
.collect();
tx_index.update(block.header, &map);
}
}
}
tx_index
}
/// Gets an item from the index if present. [None] otherwise.
pub fn get<'a>(&'a self, k: &'a K) -> Option<&V> {
self.index.get(k)
}
/// Checks if the index if full.
pub fn is_full(&self) -> bool {
self.blocks.len() > self.size
}
/// Get's the height of a given block based on its position in the block queue.
pub fn get_height(&self, block_hash: &BlockHash) -> Option<usize> {
let pos = self.blocks.iter().position(|x| x == block_hash)?;
Some(self.tip as usize + pos + 1 - self.blocks.len())
}
/// Updates the index by adding data from a new block. Removes the oldest block if the index is full afterwards.
pub fn update(&mut self, block_header: Header, data: &HashMap<K, V>) {
self.blocks.push_back(block_header.block_hash());
let ks = data
.iter()
.map(|(k, v)| {
self.index.insert(*k, v.clone());
*k
})
.collect();
self.tx_in_block.insert(block_header.block_hash(), ks);
if self.is_full() {
// Avoid logging during bootstrap
log::debug!("New block added to index: {}", block_header.block_hash());
self.tip += 1;
self.remove_oldest_block();
}
}
/// Fixes the index by removing disconnected data.
pub fn remove_disconnected_block(&mut self, block_hash: &BlockHash) {
if let Some(ks) = self.tx_in_block.remove(block_hash) {
self.index.retain(|k, _| !ks.contains(k));
// Blocks should be disconnected from last backwards. Log if that's not the case so we can revisit this and fix it.
if let Some(ref h) = self.blocks.pop_back() {
if h != block_hash {
log::error!("Disconnected block does not match the oldest block stored in the TxIndex ({block_hash} != {h})");
}
}
} else {
log::warn!("The index is already empty");
}
}
/// Removes the oldest block from the index.
/// This removes data from `self.blocks`, `self.tx_in_block` and `self.index`.
pub fn remove_oldest_block(&mut self) {
let h = self.blocks.pop_front().unwrap();
let ks = self.tx_in_block.remove(&h).unwrap();
self.index.retain(|k, _| !ks.contains(k));
log::debug!("Oldest block removed from index: {h}");
}
}
impl<K: std::fmt::Debug + Key, V: std::fmt::Debug + Value> fmt::Display for TxIndex<K, V> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"index: {:?}\n\nblocks: {:?}\n\ntx_in_block: {:?}\n\nsize: {}",
self.index, self.blocks, self.tx_in_block, self.size
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::ops::Deref;
use crate::test_utils::{get_full_block, get_full_blocks, get_last_n_blocks, Blockchain};
use bitcoin::hashes::serde_macros::serde_details::SerdeHash;
use bitcoin::Block;
impl<K, V> TxIndex<K, V>
where
K: Key + std::cmp::Eq + Copy,
V: Value + Clone,
Self: Sized,
{
pub fn index_mut(&mut self) -> &mut HashMap<K, V> {
&mut self.index
}
pub fn blocks(&self) -> &VecDeque<BlockHash> {
&self.blocks
}
pub fn contains_key(&self, k: &K) -> bool {
self.index.contains_key(k)
}
}
#[tokio::test]
async fn test_new() {
let height = 10;
let mut chain = Blockchain::default().with_height(height as usize);
let last_six_blocks = get_last_n_blocks(&mut chain, 6).await;
let blocks: Vec<Block> = get_full_blocks(&last_six_blocks);
let cache: TxIndex<Locator, Transaction> = TxIndex::new(&last_six_blocks, height);
assert_eq!(blocks.len(), cache.size);
for block in blocks.iter() {
assert!(cache.blocks().contains(&block.block_hash()));
let mut locators = Vec::new();
for tx in block.txdata.iter() {
let locator = Locator::new(tx.compute_txid());
assert!(cache.contains_key(&locator));
locators.push(locator);
}
assert_eq!(cache.tx_in_block[&block.block_hash()], locators);
}
}
#[tokio::test]
async fn test_get_height() {
let cache_size = 10;
let height = 50;
let mut chain = Blockchain::default().with_height_and_txs(height, 42);
let last_n_blocks = get_last_n_blocks(&mut chain, cache_size).await;
// last_n_blocks is ordered from latest to earliest
let first_block = get_full_block(last_n_blocks.get(cache_size - 1).unwrap());
let last_block = get_full_block(last_n_blocks.first().unwrap());
let mid_block = get_full_block(last_n_blocks.get(cache_size / 2).unwrap());
let cache: TxIndex<Locator, Transaction> = TxIndex::new(&last_n_blocks, height as u32);
assert_eq!(
cache.get_height(&first_block.header.block_hash()).unwrap(),
height - cache_size + 1
);
assert_eq!(
cache.get_height(&last_block.header.block_hash()).unwrap(),
height
);
assert_eq!(
cache.get_height(&mid_block.header.block_hash()).unwrap(),
height - cache_size / 2
);
}
#[tokio::test]
async fn test_get_height_not_found() {
let cache_size = 10;
let height = 50;
let mut chain = Blockchain::default().with_height_and_txs(height, 42);
let cache: TxIndex<Locator, Transaction> = TxIndex::new(
&get_last_n_blocks(&mut chain, cache_size).await,
height as u32,
);
let fake_hash = &BlockHash::from_slice_delegated(&[0; 32]).unwrap();
assert!(cache.get_height(fake_hash).is_none());
}
#[tokio::test]
async fn test_update() {
let height = 10;
let mut chain = Blockchain::default().with_height(height as usize);
let mut last_n_blocks = get_last_n_blocks(&mut chain, 7).await;
// Store the last block to use it for an update and the first to check eviction
// Notice that the list of blocks is ordered from last to first.
let last_block = last_n_blocks.remove(0);
let first_block = last_n_blocks.last().unwrap();
// Init the cache with the 6 block before the last
let mut cache = TxIndex::new(&last_n_blocks, height);
// Update the cache with the last block
let full_block = get_full_block(&last_block);
let locator_tx_map = full_block
.txdata
.iter()
.map(|tx| (Locator::new(tx.compute_txid()), tx.clone()))
.collect();
let header = full_block.header;
cache.update(header, &locator_tx_map);
// Check that the new data is in the cache
assert!(cache.blocks().contains(&header.block_hash()));
for (locator, _) in locator_tx_map.iter() {
assert!(cache.contains_key(locator));
}
let block_hash = full_block.header.block_hash();
assert_eq!(
cache.tx_in_block[&block_hash],
locator_tx_map.keys().cloned().collect::<Vec<Locator>>()
);
// Check that the data from the first block has been evicted
let first_full_block = get_full_block(first_block);
let tx = first_full_block.txdata[0].clone();
assert!(!cache.contains_key(&Locator::new(tx.compute_txid())));
let block_hash = first_full_block.header.block_hash();
assert!(!cache.tx_in_block.contains_key(&block_hash));
}
#[tokio::test]
async fn test_remove_disconnected_block() {
let cache_size = 6;
let height = cache_size * 2;
let mut chain = Blockchain::default().with_height_and_txs(height, 42);
let mut cache: TxIndex<Locator, Transaction> = TxIndex::new(
&get_last_n_blocks(&mut chain, cache_size).await,
height as u32,
);
// TxIndex::fix removes the last connected block and removes all the associated data
for i in 0..cache_size {
let header = chain
.at_height(chain.get_block_count() as usize - i)
.deref()
.header;
let locators = cache.tx_in_block.get(&header.block_hash()).unwrap().clone();
// Make sure there's data regarding the target block in the cache before fixing it
assert_eq!(cache.blocks().len(), cache.size - i);
assert!(cache.blocks().contains(&header.block_hash()));
assert!(!locators.is_empty());
for locator in locators.iter() {
assert!(cache.contains_key(locator));
}
cache.remove_disconnected_block(&header.block_hash());
// Check that the block data is not in the cache anymore
assert_eq!(cache.blocks().len(), cache.size - i - 1);
assert!(!cache.blocks().contains(&header.block_hash()));
assert!(!cache.tx_in_block.contains_key(&header.block_hash()));
for locator in locators.iter() {
assert!(!cache.contains_key(locator));
}
}
// At this point the cache should be empty, fixing it further shouldn't do anything
for i in cache_size..cache_size * 2 {
assert!(cache.index.is_empty());
assert!(cache.blocks().is_empty());
assert!(cache.tx_in_block.is_empty());
let header = chain
.at_height(chain.get_block_count() as usize - i)
.deref()
.header;
cache.remove_disconnected_block(&header.block_hash());
}
}
}

File diff suppressed because it is too large Load diff

36
watchtower-plugin/Cargo.toml Executable file
View file

@ -0,0 +1,36 @@
[package]
name = "watchtower-plugin"
version = "0.2.0"
authors = ["Sergi Delgado Segura <sergi.delgado.s@gmail.com>"]
license = "MIT"
edition = "2021"
[[bin]]
name = "watchtower-client"
path = "src/main.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
# General
backoff = { version = "0.4.0", features = ["tokio"] }
hex = { version = "0.4.3", features = [ "serde" ] }
home = "0.5.3"
reqwest = { version = "0.11", features = [ "blocking", "json", "socks" ] }
log = "0.4.16"
rusqlite = { version = "0.26.0", features = [ "bundled", "limits" ] }
serde = "1.0.130"
serde_json = { version = "1.0", features = [ "preserve_order" ] }
tonic = { version = "0.11", features = [ "tls", "transport" ] }
tokio = { version = "1.5", features = [ "rt-multi-thread", "fs" ] }
# Bitcoin and Lightning
bitcoin = "0.32.0"
cln-plugin = "0.3.0"
# Local
teos-common = { path = "../teos-common" }
[dev-dependencies]
mockito = "0.32.4"
tempdir = "0.3.7"

300
watchtower-plugin/README.md Normal file
View file

@ -0,0 +1,300 @@
# Watchtower client
This is a watchtower client plugin to interact with an [Eye of Satoshi tower](https://github.com/talaia-labs/rust-teos),
and eventually with any [BOLT13](https://github.com/sr-gi/bolt13/blob/master/13-watchtowers.md) compliant watchtower.
The plugin manages all the client-side logic to send appointment to a number of registered towers every time a new
commitment transaction is generated. It also keeps a summary of the messages sent to the towers and their responses.
The plugin has the following methods:
- `registertower <tower_id>`: registers the user id (compressed public key) with a given tower.
- `gettowerinfo <tower_id>`: gets all the locally stored data about a given tower.
- `retrytower <tower_id>`: tries to send pending appointment to a (previously) unreachable tower.
- `abandontower <tower_id>`: deletes all data associated with a given tower.
- `pingtower <tower_id>`: Polls the tower to check if it is online.
- `listtowers`: lists all registered towers.
- `getappointment <tower_id> <locator>`: queries a given tower about an appointment.
- `getsubscriptioninfo <tower_id>`: gets the subscription information by querying the tower.
- `getappointmentreceipt <tower_id> <locator>`: pulls a given appointment receipt from the local database.
- `getregistrationreceipt <tower_id>`: pulls the latest registration receipt from the local database.
The plugin also has an implicit method to send appointments to the registered towers for every new commitment transaction.
# Installing the plugin and linking it to CLN
The first step to add the plugin to CLN is installing it. To do so you need to run (from the `rust-teos` folder):
```
cargo install --locked --path watchtower-plugin
```
That will generate a binary called `watchtower-client`. That's the binary we need to link to CLN.
You can link the plugin either via cmd or by placing it in the plugins folder.
### Linking the plugin via cmd
To link the plugin via cmd simply run `lightningd` specifying the plugin to link, that is:
```
lightningd --plugin=watchtower-client
```
Notice that you'll need to do this every time you restart your node.
### Linking the plugin via the plugins folder
You can also add a plugin by adding it to the plugins folder so you don't have to link it every single time. In order to do so you need to go to the CLN user directory in your machine (that's usually `~/.lightning`). Once there, you need to create a folder called `plugins` if it does not exist:
```
cd ~/.lightning
mkdir plugins && cd plugins
```
Now you need to place the watchtower-client into this folder. To do so we will create a symbolic link to it. First, check where the binary is placed (this is usually placed in your user's home).
```
whereis watchtower-client
> watchtower-client: <your_user_home>/.cargo/bin/watchtower-client
```
Notice that here `<your_user_home>` will be the path to your user's home directory, for instance `/home/sergi/`.
Now create a symbolic link to it (make sure to replace the path for your's!):
```
ln -s <your_user_home>/.cargo/bin/watchtower-client .
```
If you check the folder you'll see that now there's a link called `watchtower-client`:
```
ls
> watchtower-client
```
You can now turn on your lightning node and the plugin will be automatically linked.
### Check that the client is properly linked
We can check the plugin was properly linked by running:
```
lightning-cli plugin list
```
That will return a list of all linked plugins, which should include the `watchtower-client`:
```
[
...
{
"name": "~/.lightning/plugins/watchtower-client",
"active": true
}
]
```
# Config file, data folder and first bootstrap
The plugin, by default, creates a data folder under the user's home folder (`~/.watchtower`), where all the plugin's data is stored. The data folder can be modified by setting the ENV variable `TOWERS_DATA_DIR`.
On first bootstrap, the plugin generates a key pair that is used as the user identifier. All requests from the user are signed using the secret key, so the tower can authenticate the user after the registration process (`registertower`).
All the appointments generated by the tower, as well as all the registered towers' data, are stored on a `SQLite3` database under the data dir (that's `~/.watchtower/watchtowers_db.sql3` for the default data dir).
# Core Lightning (CLN) config
Config options can be setup directly in the [CLN config file](https://github.com/ElementsProject/lightning#configuration-file). The currently available options are:
- `watchtower-port`: default tower API port.
- `watchtower-max-retry-time`: for how long (in seconds) a retry strategy will try to reach a temporary unreachable tower before giving up (default: 1 hour).
- `watchtower-auto-retry-delay`: how long (in seconds) the client will wait before auto-retrying a failed tower (default: 8 hours).
- `proxy`: Set a socks v5 proxy IP address and port. Notice this is necessary if you want to connect to a tower through Tor! (default: no proxy).
- `always-use-proxy`: Use the proxy always (default: false).
Notice `proxy` and `always-use-proxy` are general CLN options that are honored by the plugin, so if set the plugin will use Tor to communicate with the tower.
# Getting started
## Registering with a tower
Once the plugin is loaded in your node, the first step is to register your node with an active tower. You can do so by running:
```
lightning-cli registertower tower_id [host, port]
```
Where `tower_id` represents the target tower public key. As a convenience, `tower_id` may be of the form `tower_id@host` or `id@host:port`. In this case, the host and port parameters must be omitted. Port defaults to `9814` and can be changed in the config file.
### Example
```
lightning-cli registertower 02bd2b759dd8a4fcef0f7d9692c105da8400d5da7942ee039e869fbfb8738ffde4
```
If the tower is online, you should get back a response similar to this:
```
{
"user_id": "032fd79e4052531955cf3782b09b495a75919317573ba2fb4dca199652595ced2a",
"available_slots": 10000,
"subscription_expiry": 4712
}
```
Where `available_slots` is the amount of free slots the user has available in the tower, `user_id` is the user's public key and `subscription_expiry` is the block height when the subscription expires. Generally speaking, a slot fits an appointment, so in this example the user can send **10000** appointments in roughly **one month**.
Notice that, ideally, the client and the tower have to agree on the **subscription details** (`available_slots` and `subscription_expiry`). Currently, those depend only on the tower, since it is offering the service for free. However, in the current state, hitting `registertower` again will add another `10000` slots and reset the time to `current_height + roughtly_one_mont_in_blocks`.
## Sending data to the tower
Once your node is registered with at least one tower it will start sending appointments to the tower for every commitment transaction update on any of your channels. In the current version of the plugin, everything is sent to every registered tower (**full replication**). There is nothing to be done here, under normal conditions, the plugin takes care of it.
## Checking the state of the towers
To find out more information about registered towers, you can use `list_towers` and `gettowerinfo`:
```
lightning-cli listtowers
```
```
{
"02bd2b759dd8a4fcef0f7d9692c105da8400d5da7942ee039e869fbfb8738ffde4": {
"net_addr": "http://localhost:9814",
"available_slots": 9996,
"subscription_expiry": 4712,
"status": "reachable",
"pending_appointments": [],
"invalid_appointments": []
}
}
```
The overview contains the `id` and network address of the tower (`netaddr`), as well as the current `status` and two list of appointments: **pending** and **invalid**.
The tower has 5 different states:
- `reachable`: the tower is reachable at the given network address.
- `temporarily unreachable`: the tower is temporarily unreachable, meaning that one of the last requests sent to it has failed.
- `unreachable`: the tower has been unreachable for a while.
- `misbehaving`: the tower has sent us incorrect data.
- `subscription error`: the subscription with the tower has expired or run out of slots.
The main difference between `temporarily unreachable` and `unreachable` is the amount of time that has passed since we last received a response. If a tower is temporarily unreachable, a backoff strategy is triggered and all the appointments that cannot be delivered are stored under `pending_appointments`. If the tower comes back online within the retry strategy, every pending appointment is sent through and the tower is flagged back as `reachable`. However, if the backoff strategy ends up giving up, the tower is flagged as `unreachable`.
If the client receives data from a tower that is not properly signed, the tower is flagged as `misbehaving` and it is abandoned, meaning that no more appointments are sent to it. This state should never be reached by honest towers.
A `subscription error` means that the subscription needs to be renewed (hit `registertower` again).
Regarding `pending_appointments` and `invalid_appointments` they store the data that is pending to be sent to the tower (for unreachable towers) and the appointments that have been rejected by the tower for being invalid, respectively. The latter should never get populated for honest clients.
`gettowerinfo` provides more detailed information about the tower:
**Usage**
```
lightning-cli gettowerinfo tower_id
```
**Call**
```
lightning-cli gettowerinfo 02bd2b759dd8a4fcef0f7d9692c105da8400d5da7942ee039e869fbfb8738ffde4
```
**Return**
```
{
"net_addr": "http://localhost:9814",
"available_slots": 9996,
"subscription_expiry": 4712,
"status": "reachable",
"appointments": {
"b851b8ec05f5809b9a710f7d9d24db6c": "rbxrs8ncqgzyrxkw5h95a64tbeyhmx6wopdtqndktkko3mq8q3tkczjyk19epd713it8warbpnxgk8py6utq87dt16f3qk6ehkjw5c7q",
"10c6f7787fc33d6298fa89fc41f6a0eb": "dhrtt91bbswmmu41nu4quszt7bsxzpfyx84ycfc1yjt73rs8eqpqg3fwqq8q9tff8aqorohueo3bcgqrww1ocef38hdfuhna44ikjife",
"52dc9bd565bdfe227111927e3964d70b": "d9495n3giiof4rq5aqzh4a6fezftnhofwdi1gb7q5mciyq9besdh4xixczitpgo5dxzdnyzzdy4b9i7hd1zcojdgw833975dn8azfc7x",
"e2824d355f711806d38671c19b91110d": "rbxhpeztw74dspxsr3tk7jdekw7cbkt88kfmda4guf5xkmh1tcmeauqf3s15168y8eo438nbpath58qrxsh9usskzmxk8suf1h19meae"
},
"pending_appointments": {
"062dc0f28ce5b31e6902c87ff1de15ee": {
"encrypted_blob": "e91bf1a1ab097f71976f240fb2d0c036f5b2188f14089dd1960e041b0a4d31a2bcbf9d6bec064a1d81471bdacf1f4d3b7c8d5df280d86a44504a5ee2ebf309adadc4976cc48cef7b94c9a8f17a16f0dcddfd6d0d105621bc519c0f20b46a8335a3a091bf6bfcc813bd4e34e644822bddda81b2a829d8a3b522b4c9b3f4465a6e416ae9ca8c808637cbc51e8d73dfe80cad3a6cc8c5ca018dd8a4cf2edbc02fd5f6cee0aef5ed5411731ef89061272712180c04150652f5bbb1b540ccc72547fe4ca5e92819c3bbb2feeccd7ce8f7b6568dd7f725fefdd64684f63d59e5d719b24a11272c64818b6319c19a261ee9c8a1674eb2e7c7367797893ac8",
"to_self_delay": 42
},
"ad229060698d4bc2b910a30933b1b50a": {
"encrypted_blob": "5881dce52efc18b698adc4f93b4ba275eb73271645b471227680ddb889ab60870972c4d44278dc55da4502021d9af67fd4e40803a2c9a6b4d2fe1d1f89b93373407302b67d12bb6c90b6e72b073f1c6bb3c69d57e635bfd5ff2b9648812364821b30bd95b3e8b3b2a888da8225e3d4d5cd2cd1cf2705d022b908b6b2d71c155ea50c38e2b3fb45a615c7bc1d61607a9240999c1bf174d6153b4ca7d086586614c99a45d7195c589fda8101ee8801e28b7ccad7c2b5fbda38cfe7b5e8ec13c23b8fa3cc3e6791ea9675f312cd59278ea0434538d15600b7fc905cf5a8371fc93d1e834e16d5c6399127b71c8f5c9ebfc11c5c8d3f72ce92e278f163",
"to_self_delay": 42
}
},
"invalid_appointments": {}
}
```
Notice that there are is a new field in this report: `appointments`.
`appointments` contains a collection of `locator:tower_signature` pairs of all the appointments sent and accepted by the tower.
The report may also contain a `misbehaving_proof` field if the tower has misbehaved (this is not the case for this example). The proof would look as follows:
```
"misbehaving_proof": {
"locator": "3ebd6c5a4d5ec18c815ad9fcda9aac75",
"appointment_receipt": {
"user_signature": "d7efykp63dy69jrtc3r65pssbdhp4335etq3jap1zqk135qmrtyhr8ghbdhw8y8f7nsjgmm9eoyhsfj6yugzq1bu657frmwwrudr9gpt",
"start_block": 391,
"signature": "rd41nsmhtjsawhc9pta1p5na7kmsyk48xttjy4bt3tbkbajboyzfq6mpamkjixs1w7qotocwjg3sxnbzg6uduec4cnahhkmctgddjn8w"
},
"recovered_id": "02bd2b759dd8a4fcef0f7d9692c105da8400d5da7942ee039e869fbfb8738ffde4"
}
```
Finally, notice how `pending_appointments` now contains all the data about the pending appointments (**the full appointment**). The same applies to `invalid_appointments`.
## Manually retrying a tower
If a tower has been flagged as **unreachable** (after the default backoff has failed) or there has been a **subscription error**, the tower won't be tried again until the user manually requests so. This can be managed with the `retrytower` command:
**Usage**
```
lightning-cli retrytower tower_id
```
**Call**
```
lightning-cli retrytower 02bd2b759dd8a4fcef0f7d9692c105da8400d5da7942ee039e869fbfb8738ffde4
```
**Return**
```
"Retrying 02bd2b759dd8a4fcef0f7d9692c105da8400d5da7942ee039e869fbfb8738ffde4"
```
Notice that this only works if the tower is **unreachable**. A tower cannot be retried if it is already being retried (**temporarily unreachable**).
## Query data from a tower
Data can be queried from a tower to check, for instance, that the tower is keeping it or that it is correct. This can be done using the `getappointment` command:
**Usage**
```
lightning-cli getappointment tower_id locator
```
**Call**
```
lightning-cli getappointment 02bd2b759dd8a4fcef0f7d9692c105da8400d5da7942ee039e869fbfb8738ffde4 b851b8ec05f5809b9a710f7d9d24db6c
```
**Return**
```
{
"appointment": {
"locator": "b851b8ec05f5809b9a710f7d9d24db6c",
"encrypted_blob": "017044dd0686e89bd3cf69777f1fdcb63d13eafa35e1946a0ac1324247ed793f11e27b3ee599bb1676cc98862c1f07d8e5bd29ed51c94c4ea2721a2b6f205f11cbdb1478da413ced585fe5069c6f438e977d325499bdedb985c055eaff00466209007587f20d09d153b537b0b1b6f5b8151384a1ad9f94dfffd5d5f6c2d484bad7d007976fdcaff173b18dbc4e1e24ca2ae29f8ab7e6933468c179f3857c813441e303b2e9e9b7625b19d8460d368f66cf5a7a2f54139ae0a0c9f0ef0c56183734e5dd51289ecb4f046d97e02895373c97e242c71f910c3ed1fc1b32eda4a3c28c73ad7e5fef624094fadb0753c03f8c9a4189a427e721f3ddfc0a",
"to_self_delay": 42
},
"status": "being_watched"
}
```

View file

@ -0,0 +1,52 @@
// Collection of ENV variable names and values
pub const TOWERS_DATA_DIR: &str = "TOWERS_DATA_DIR";
pub const DEFAULT_TOWERS_DATA_DIR: &str = ".watchtower";
/// Collections of plugin option names, default values and descriptions
pub const WT_PORT: &str = "watchtower-port";
pub const DEFAULT_WT_PORT: i64 = 9814;
pub const WT_PORT_DESC: &str = "tower API port";
pub const WT_MAX_RETRY_TIME: &str = "watchtower-max-retry-time";
pub const DEFAULT_WT_MAX_RETRY_TIME: i64 = 3600;
pub const WT_MAX_RETRY_TIME_DESC: &str = "for how long (in seconds) a retry strategy will try to reach a temporary unreachable tower before giving up. Defaults to 1 hour";
pub const WT_AUTO_RETRY_DELAY: &str = "watchtower-auto-retry-delay";
pub const DEFAULT_WT_AUTO_RETRY_DELAY: i64 = 28800;
pub const WT_AUTO_RETRY_DELAY_DESC: &str = "how long (in seconds) a retrier will wait before auto-retrying a failed tower. Defaults to once every 8 hours";
pub const DEV_WT_MAX_RETRY_INTERVAL: &str = "dev-watchtower-max-retry-interval";
pub const DEFAULT_DEV_WT_MAX_RETRY_INTERVAL: i64 = 900;
pub const DEV_WT_MAX_RETRY_INTERVAL_DESC: &str =
"maximum length (in seconds) for a retry interval. Defaults to 15 min";
/// Collections of rpc method names and descriptions
pub const RPC_REGISTER_TOWER: &str = "registertower";
pub const RPC_REGISTER_TOWER_DESC: &str =
"Registers the client public key (user id) with the tower";
pub const RPC_GET_REGISTRATION_RECEIPT: &str = "getregistrationreceipt";
pub const RPC_GET_REGISTRATION_RECEIPT_DESC: &str =
"Gets the latest registration receipt given a tower id";
pub const RPC_GET_APPOINTMENT: &str = "getappointment";
pub const RPC_GET_APPOINTMENT_DESC: &str =
"Gets appointment data from the tower given a tower id and a locator";
pub const RPC_GET_APPOINTMENT_RECEIPT: &str = "getappointmentreceipt";
pub const RPC_GET_APPOINTMENT_RECEIPT_DESC: &str =
"Gets a (local) appointment receipt given a tower id and a locator";
pub const RPC_GET_SUBSCRIPTION_INFO: &str = "getsubscriptioninfo";
pub const RPC_GET_SUBSCRIPTION_INFO_DESC: &str =
"Gets the subscription information directly from the tower";
pub const RPC_LIST_TOWERS: &str = "listtowers";
pub const RPC_LIST_TOWERS_DESC: &str = "Lists all registered towers";
pub const RPC_GET_TOWER_INFO: &str = "gettowerinfo";
pub const RPC_GET_TOWER_INFO_DESC: &str = "Shows the info about a tower given a tower id";
pub const RPC_RETRY_TOWER: &str = "retrytower";
pub const RPC_RETRY_TOWER_DESC: &str =
"Retries to send pending appointment to an unreachable tower";
pub const RPC_ABANDON_TOWER: &str = "abandontower";
pub const RPC_ABANDON_TOWER_DESC: &str = "Forgets about a tower and wipes all local data";
pub const RPC_PING: &str = "pingtower";
pub const RPC_PING_DESC: &str = "Polls the tower to check if it is online";
/// Collections of hook names
pub const HOOK_COMMITMENT_REVOCATION: &str = "commitment_revocation";

View file

@ -0,0 +1,562 @@
use std::fmt;
use std::{convert::TryFrom, str::FromStr};
use hex::FromHex;
use serde::{Deserialize, Serialize};
use serde_json::json;
use bitcoin::{Transaction, Txid};
use teos_common::appointment::Locator;
use teos_common::TowerId;
/// Errors related to the `registertower` command.
#[derive(Debug)]
pub enum RegisterError {
InvalidId(String),
InvalidHost(String),
InvalidPort(String),
InvalidFormat(String),
}
impl std::fmt::Display for RegisterError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
RegisterError::InvalidId(x) => write!(f, "{x}"),
RegisterError::InvalidHost(x) => write!(f, "{x}"),
RegisterError::InvalidPort(x) => write!(f, "{x}"),
RegisterError::InvalidFormat(x) => write!(f, "{x}"),
}
}
}
/// Parameters related to the `registertower` command.
#[derive(Debug, Serialize)]
pub struct RegisterParams {
pub tower_id: TowerId,
pub host: Option<String>,
pub port: Option<u16>,
}
impl RegisterParams {
fn new(tower_id: &str, host: Option<&str>, port: Option<u64>) -> Result<Self, RegisterError> {
let mut params = RegisterParams::from_id(tower_id)?;
if host.is_some() {
params = params.with_host(host.unwrap())?
}
if port.is_some() {
params = params.with_port(port.unwrap())?
}
Ok(params)
}
fn from_id(tower_id: &str) -> Result<Self, RegisterError> {
Ok(Self {
tower_id: TowerId::from_str(tower_id)
.map_err(|_| RegisterError::InvalidId("Invalid tower id".to_owned()))?,
host: None,
port: None,
})
}
fn with_host(self, host: &str) -> Result<Self, RegisterError> {
if host.is_empty() {
Err(RegisterError::InvalidHost("hostname is empty".to_owned()))
} else if host.contains(' ') {
Err(RegisterError::InvalidHost(
"hostname contains white spaces".to_owned(),
))
} else {
Ok(Self {
host: Some(String::from(host)),
..self
})
}
}
fn with_port(self, port: u64) -> Result<Self, RegisterError> {
if port > u16::MAX as u64 {
Err(RegisterError::InvalidPort(format!(
"port must be a 16-byte integer. Received: {port}"
)))
} else {
Ok(Self {
port: Some(port as u16),
..self
})
}
}
}
impl TryFrom<serde_json::Value> for RegisterParams {
type Error = RegisterError;
// clippy-fix: We are getting more than just the first item, so this clippy check does not make sense here
#[allow(clippy::get_first)]
fn try_from(value: serde_json::Value) -> Result<Self, Self::Error> {
match value {
serde_json::Value::String(s) => {
let s = s.trim();
let mut v = s.split('@');
let tower_id = v.next().unwrap();
match v.next() {
Some(x) => {
let mut v = x.split(':');
let host = v.next();
let port = if let Some(p) = v.next() {
p.parse()
.map(Some)
.map_err(|_| RegisterError::InvalidPort(format!("Port is not a number: {p}")))?
} else {
None
};
RegisterParams::new(tower_id, host, port)
}
None => RegisterParams::from_id(tower_id),
}
},
serde_json::Value::Array(mut a) => {
let param_count = a.len();
match param_count {
1 => RegisterParams::try_from(a.pop().unwrap()),
2 | 3 => {
let tower_id = a.get(0).unwrap().as_str().ok_or_else(|| RegisterError::InvalidId("tower_id must be a string".to_string()))?;
let host = Some(a.get(1).unwrap().as_str().ok_or_else(|| RegisterError::InvalidHost("host must be a string".to_string()))?);
let port = if let Some(p) = a.get(2) {
Some(p.as_u64().ok_or_else(|| RegisterError::InvalidPort(format!("port must be a number. Received: {p}")))?)
} else {
None
};
RegisterParams::new(tower_id, host, port)
}
_ => Err(RegisterError::InvalidFormat(format!("Unexpected request format. The request needs 1-3 parameters. Received: {param_count}"))),
}
},
serde_json::Value::Object(mut m) => {
let allowed_keys = ["tower_id", "host", "port"];
let param_count = m.len();
if m.is_empty() || param_count > allowed_keys.len() {
Err(RegisterError::InvalidFormat(format!("Unexpected request format. The request needs 1-3 parameters. Received: {param_count}")))
} else if !m.contains_key(allowed_keys[0]){
Err(RegisterError::InvalidId(format!("{} is mandatory", allowed_keys[0])))
} else if !m.iter().all(|(k, _)| allowed_keys.contains(&k.as_str())) {
Err(RegisterError::InvalidFormat("Invalid named parameter found in request".to_owned()))
} else {
let mut params = Vec::with_capacity(allowed_keys.len());
for k in allowed_keys {
if let Some(v) = m.remove(k) {
params.push(v);
}
}
RegisterParams::try_from(json!(params))
}
},
_ => Err(RegisterError::InvalidFormat(
format!("Unexpected request format. Expected: 'tower_id[@host][:port]' or 'tower_id [host] [port]'. Received: '{value}'"),
)),
}
}
}
/// Errors related to the `getappointment` command.
#[derive(Debug)]
pub enum GetAppointmentError {
InvalidId(String),
InvalidLocator(String),
InvalidFormat(String),
}
impl std::fmt::Display for GetAppointmentError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
GetAppointmentError::InvalidId(x) => write!(f, "{x}"),
GetAppointmentError::InvalidLocator(x) => write!(f, "{x}"),
GetAppointmentError::InvalidFormat(x) => write!(f, "{x}"),
}
}
}
/// Parameters related to the `getappointment` command.
#[derive(Debug)]
pub struct GetAppointmentParams {
pub tower_id: TowerId,
pub locator: Locator,
}
impl TryFrom<serde_json::Value> for GetAppointmentParams {
type Error = GetAppointmentError;
// clippy-fix: We are getting more than just the first item, so this clippy check does not make sense here
#[allow(clippy::get_first)]
fn try_from(value: serde_json::Value) -> Result<Self, Self::Error> {
match value {
serde_json::Value::Array(a) => {
let param_count = a.len();
if param_count != 2 {
Err(GetAppointmentError::InvalidFormat(format!(
"Unexpected request format. The request needs 2 parameter. Received: {param_count}"
)))
} else {
let tower_id = if let Some(s) = a.get(0).unwrap().as_str() {
TowerId::from_str(s).map_err(|_| {
GetAppointmentError::InvalidId("Invalid tower id".to_owned())
})
} else {
Err(GetAppointmentError::InvalidId(
"tower_id must be a hex encoded string".to_owned(),
))
}?;
let locator = if let Some(s) = a.get(1).unwrap().as_str() {
Locator::from_hex(s).map_err(|_| {
GetAppointmentError::InvalidLocator("Invalid locator".to_owned())
})
} else {
Err(GetAppointmentError::InvalidLocator(
"locator must be a hex encoded string".to_owned(),
))
}?;
Ok(Self { tower_id, locator })
}
}
serde_json::Value::Object(mut m) => {
let allowed_keys = ["tower_id", "locator"];
if m.len() > allowed_keys.len() {
return Err(GetAppointmentError::InvalidFormat(
"Invalid named argument found in request".to_owned(),
));
}
// DISCUSS: There may be a more idiomatic way of doing this
for k in allowed_keys.iter() {
if !m.contains_key(*k) {
return Err(GetAppointmentError::InvalidFormat(format!(
"{k} is mandatory"
)));
}
}
let mut params = Vec::with_capacity(allowed_keys.len());
for k in allowed_keys {
if let Some(v) = m.remove(k) {
params.push(v);
}
}
GetAppointmentParams::try_from(json!(params))
}
_ => Err(GetAppointmentError::InvalidFormat(format!(
"Unexpected request format. Expected: tower_id locator. Received: '{value}'"
))),
}
}
}
/// Data associated with a commitment revocation. Represents the data sent by CoreLN through the `commitment_revocation` hook.
#[derive(Debug, Serialize, Deserialize)]
pub struct CommitmentRevocation {
pub channel_id: String,
#[serde(rename(deserialize = "commitnum"))]
pub commit_num: u32,
pub commitment_txid: Txid,
#[serde(deserialize_with = "crate::ser::deserialize_tx")]
pub penalty_tx: Transaction,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::collections::HashMap;
const VALID_ID: &str = "020dea894c967319407265764aba31bdef75d463f96800f34dd6df61380d82dfc0";
mod register_command {
use super::*;
#[test]
fn test_from_id() {
// The tower id should be a valid id, otherwise the params construction will fail
let params = RegisterParams::from_id(VALID_ID).unwrap();
assert!(params.host.is_none());
assert!(params.port.is_none());
// Any incorrectly formatted id will make it fail
assert!(matches!(
RegisterParams::from_id(""),
Err(RegisterError::InvalidId(..))
));
}
#[test]
fn test_with_host() {
// Any properly formatted host should work
let params = RegisterParams::from_id(VALID_ID).unwrap();
let host = "myhost";
assert_eq!(params.with_host(host).unwrap().host, Some(host.to_owned()));
// Host must not be empty not have spaces
assert!(matches!(
RegisterParams::from_id(VALID_ID).unwrap().with_host(""),
Err(RegisterError::InvalidHost(..))
));
assert!(matches!(
RegisterParams::from_id(VALID_ID)
.unwrap()
.with_host("myhost "),
Err(RegisterError::InvalidHost(..))
));
}
#[test]
fn test_with_port() {
let mut params = RegisterParams::from_id(VALID_ID).unwrap();
// Any 16-bytes value will do for the port
let port = 6677;
params = params.with_port(port).unwrap();
assert_eq!(params.port, Some(port as u16));
// Going over u16::MAX will make this fail
let port = u16::MAX as u64 + 1;
assert!(matches!(
params.with_port(port),
Err(RegisterError::InvalidPort(..))
));
}
#[test]
fn test_try_from_json_string() {
let ok = [
format!("{VALID_ID}@host:80"),
format!("{VALID_ID}@host"),
VALID_ID.to_string(),
];
let wrong_id = ["", "id@host:80", "@host:80", "@:80"];
let wrong_host = [
format!("{VALID_ID}@"),
format!("{VALID_ID}@ "),
format!("{VALID_ID}@ host"),
format!("{VALID_ID}@:80"),
];
let wrong_port = [format!("{VALID_ID}@host:"), format!("{VALID_ID}@host:port")];
for s in ok {
let v = serde_json::Value::Array(vec![serde_json::Value::String(s.to_string())]);
let p = RegisterParams::try_from(v);
assert!(matches!(p, Ok(..)));
}
for s in wrong_id {
let v = serde_json::Value::Array(vec![serde_json::Value::String(s.to_string())]);
let p = RegisterParams::try_from(v);
assert!(matches!(p, Err(RegisterError::InvalidId(..))));
}
for s in wrong_host {
let v = serde_json::Value::Array(vec![serde_json::Value::String(s.to_string())]);
let p = RegisterParams::try_from(v);
assert!(matches!(p, Err(RegisterError::InvalidHost(..))));
}
for s in wrong_port {
let v = serde_json::Value::Array(vec![serde_json::Value::String(s.to_string())]);
let p = RegisterParams::try_from(v);
assert!(matches!(p, Err(RegisterError::InvalidPort(..))));
}
}
#[test]
fn test_try_from_json_array() {
let id = json!(VALID_ID);
let number_id = json!(0);
let host = json!("host");
let number_host = json!(1);
let port = json!(80);
let string_port = json!("80");
for v in [vec![&id, &host, &port], vec![&id, &host], vec![&id]] {
let p = RegisterParams::try_from(json!(v));
assert!(matches!(p, Ok(..)));
}
// Wrong id
let p = RegisterParams::try_from(json!(vec![&number_id, &host, &port]));
assert!(matches!(p, Err(RegisterError::InvalidId(..))));
// Wrong host
let p = RegisterParams::try_from(json!(vec![&id, &number_host, &port]));
assert!(matches!(p, Err(RegisterError::InvalidHost(..))));
// Wrong port
let p = RegisterParams::try_from(json!(vec![&id, &host, &string_port]));
assert!(matches!(p, Err(RegisterError::InvalidPort(..))));
// Wrong param count (params should be 1-3)
let p = RegisterParams::try_from(json!(vec![&id, &host, &port, &id]));
assert!(matches!(p, Err(RegisterError::InvalidFormat(..))));
}
#[test]
fn test_try_from_json_dict() {
let id = json!(VALID_ID);
let host = json!("host");
let port = json!(80);
for v in [
HashMap::from([("tower_id", &id), ("host", &host), ("port", &port)]),
HashMap::from([("tower_id", &id), ("host", &host)]),
HashMap::from([("tower_id", &id)]),
] {
let p = RegisterParams::try_from(json!(v));
assert!(matches!(p, Ok(..)));
}
// Id key missing
let p =
RegisterParams::try_from(json!(HashMap::from([("host", &host), ("port", &port)])));
assert!(matches!(p, Err(RegisterError::InvalidId(..))));
// Wrong id key
let p = RegisterParams::try_from(json!(HashMap::from([
("wrong_tower_id", &id),
("tower_id", &id),
("host", &host),
("port", &port)
])));
assert!(matches!(p, Err(RegisterError::InvalidFormat(..))));
// Wrong host key
let p = RegisterParams::try_from(json!(HashMap::from([
("tower_id", &id),
("wrong_host", &host),
("port", &port)
])));
assert!(matches!(p, Err(RegisterError::InvalidFormat(..))));
// Wrong port key
let p = RegisterParams::try_from(json!(HashMap::from([
("tower_id", &id),
("host", &host),
("wrong_port", &port)
])));
assert!(matches!(p, Err(RegisterError::InvalidFormat(..))));
// Wrong param count (params should be 1-3)
let p = RegisterParams::try_from(json!(HashMap::from([
("tower_id", &id),
("host", &host),
("port", &port),
("another_param", &json!(0))
])));
assert!(matches!(p, Err(RegisterError::InvalidFormat(..))));
}
#[test]
fn test_try_from_other_json() {
// Unexpected json object (it must be either String or Array)
let p = RegisterParams::try_from(json!(true));
assert!(matches!(p, Err(RegisterError::InvalidFormat(..))));
}
}
mod get_appointment_command {
use super::*;
#[test]
fn test_try_from_array() {
let id = json!(VALID_ID);
let wrong_id =
json!("050dea894c967319407265764aba31bdef75d463f96800f34dd6df61380d82dfc0");
let number_id = json!(0);
let locator = json!("c69517f00d9482e6b1c41639f9bdfd5c");
let wrong_locator =
json!("c69517f00d9482e6b1c41639f9bdfd5cc69517f00d9482e6b1c41639f9bdfd5c");
let number_locator = json!(1);
// Valid params
let p = GetAppointmentParams::try_from(json!(vec![&id, &locator]));
assert!(matches!(p, Ok(..)));
// Wrong params
// Id is a hex string but the format is wrong (wrong prefix)
let p = GetAppointmentParams::try_from(json!(vec![&wrong_id, &locator]));
assert!(matches!(p, Err(GetAppointmentError::InvalidId(..))));
// Ud is not a hex string
let p = GetAppointmentParams::try_from(json!(vec![&number_id, &wrong_locator]));
assert!(matches!(p, Err(GetAppointmentError::InvalidId(..))));
// Locator is a hex string but not properly formatted (wrong length)
let p = GetAppointmentParams::try_from(json!(vec![&id, &wrong_locator]));
assert!(matches!(p, Err(GetAppointmentError::InvalidLocator(..))));
// Locator is not a hex string
let p = GetAppointmentParams::try_from(json!(vec![&id, &number_locator]));
assert!(matches!(p, Err(GetAppointmentError::InvalidLocator(..))));
}
#[test]
fn test_try_from_dict() {
let id = json!(VALID_ID);
let locator = json!("c69517f00d9482e6b1c41639f9bdfd5c");
// Valid params
let p = GetAppointmentParams::try_from(json!(HashMap::from([
("tower_id", &id),
("locator", &locator)
])));
assert!(matches!(p, Ok(..)));
// Wrong keys
let p = GetAppointmentParams::try_from(json!(HashMap::from([
("wrong_tower_id", &id),
("locator", &locator)
])));
assert!(matches!(p, Err(GetAppointmentError::InvalidFormat(..))));
let p = GetAppointmentParams::try_from(json!(HashMap::from([
("tower_id", &id),
("wrong_locator", &locator)
])));
assert!(matches!(p, Err(GetAppointmentError::InvalidFormat(..))));
// Too many parameters
let p = GetAppointmentParams::try_from(json!(HashMap::from([
("tower_id", &id),
("locator", &locator),
("another_param", &json!(0))
])));
assert!(matches!(p, Err(GetAppointmentError::InvalidFormat(..))));
}
#[test]
fn test_try_from_other_json() {
// Unexpected json object (it must be either String or Array)
let p = RegisterParams::try_from(json!(true));
assert!(matches!(p, Err(RegisterError::InvalidFormat(..))));
}
#[test]
fn test_wrong_param_count() {
// The param count for get_appointment must be 2.
let params_vec = [vec![], vec![1], vec![1, 2, 3]];
for params in params_vec {
let p = GetAppointmentParams::try_from(json!(params));
assert!(matches!(p, Err(..)));
}
}
}
}

1339
watchtower-plugin/src/dbm.rs Executable file

File diff suppressed because it is too large Load diff

524
watchtower-plugin/src/lib.rs Executable file
View file

@ -0,0 +1,524 @@
use std::collections::{HashMap, HashSet};
use std::fmt;
use serde::Serialize;
use teos_common::appointment::{Appointment, Locator};
use teos_common::net::NetAddr;
use teos_common::receipts::AppointmentReceipt;
use teos_common::TowerId;
pub mod constants;
pub mod convert;
pub mod dbm;
pub mod net;
pub mod retrier;
mod ser;
pub mod wt_client;
#[cfg(test)]
mod test_utils;
/// The status the tower can be found at.
#[derive(Clone, Serialize, PartialEq, Eq, Copy, Debug)]
#[serde(rename_all = "snake_case")]
pub enum TowerStatus {
Reachable,
TemporaryUnreachable,
Unreachable,
SubscriptionError,
Misbehaving,
}
/// The status an appointment can be at.
pub enum AppointmentStatus {
Accepted,
Pending,
Invalid,
}
/// Errors related to updating a subscription
#[derive(Debug, PartialEq, Eq)]
pub enum SubscriptionError {
Expiry,
Slots,
}
impl SubscriptionError {
/// Whether the error is related to the expiry time or not.
pub fn is_expiry(&self) -> bool {
*self == SubscriptionError::Expiry
}
}
impl fmt::Display for TowerStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}",
match self {
TowerStatus::Reachable => "reachable",
TowerStatus::TemporaryUnreachable => "temporary unreachable",
TowerStatus::Unreachable => "unreachable",
TowerStatus::SubscriptionError => "subscription error",
TowerStatus::Misbehaving => "misbehaving",
}
)
}
}
impl TowerStatus {
/// Whether the tower is reachable or not.
pub fn is_reachable(&self) -> bool {
*self == TowerStatus::Reachable
}
/// Whether the tower is unreachable or not.
pub fn is_temporary_unreachable(&self) -> bool {
*self == TowerStatus::TemporaryUnreachable
}
/// Whether the tower is unreachable or not.
pub fn is_unreachable(&self) -> bool {
*self == TowerStatus::Unreachable
}
/// Whether the tower is misbehaving or not.
pub fn is_misbehaving(&self) -> bool {
*self == TowerStatus::Misbehaving
}
/// Whether there is a subscription issue with the tower.
pub fn is_subscription_error(&self) -> bool {
*self == TowerStatus::SubscriptionError
}
/// Whether the tower can be manually retried
pub fn is_retryable(&self) -> bool {
self.is_unreachable() || self.is_subscription_error()
}
}
/// Summarized data associated with a given tower.
#[derive(Clone, Serialize, Debug, PartialEq, Eq)]
pub struct TowerSummary {
#[serde(flatten)]
pub net_addr: NetAddr,
pub available_slots: u32,
subscription_start: u32,
pub subscription_expiry: u32,
pub status: TowerStatus,
#[serde(serialize_with = "teos_common::ser::serialize_locators")]
pub pending_appointments: HashSet<Locator>,
#[serde(serialize_with = "teos_common::ser::serialize_locators")]
pub invalid_appointments: HashSet<Locator>,
}
impl TowerSummary {
/// Creates a new [TowerSummary] instance.
pub fn new(
net_addr: String,
available_slots: u32,
subscription_start: u32,
subscription_expiry: u32,
) -> Self {
Self {
net_addr: NetAddr::new(net_addr),
available_slots,
subscription_start,
subscription_expiry,
status: TowerStatus::Reachable,
pending_appointments: HashSet::new(),
invalid_appointments: HashSet::new(),
}
}
/// Creates a new instance with some associated appointment data.
pub fn with_appointments(
net_addr: String,
available_slots: u32,
subscription_start: u32,
subscription_expiry: u32,
pending_appointments: HashSet<Locator>,
invalid_appointments: HashSet<Locator>,
) -> Self {
Self {
net_addr: NetAddr::new(net_addr),
available_slots,
subscription_start,
subscription_expiry,
status: TowerStatus::Reachable,
pending_appointments,
invalid_appointments,
}
}
/// Creates a new instance using the existing info but updating the status.
pub fn with_status(mut self, status: TowerStatus) -> Self {
self.status = status;
self
}
/// Updates the main information about the summary while preserving the appointment maps.
pub fn udpate(
&mut self,
net_addr: String,
available_slots: u32,
subscription_start: u32,
subscription_expiry: u32,
) {
self.net_addr = NetAddr::new(net_addr);
self.available_slots = available_slots;
self.subscription_start = subscription_start;
self.subscription_expiry = subscription_expiry;
}
}
impl From<TowerInfo> for TowerSummary {
fn from(info: TowerInfo) -> Self {
TowerSummary::with_appointments(
info.net_addr,
info.available_slots,
info.subscription_start,
info.subscription_expiry,
info.pending_appointments
.iter()
.map(|a| a.locator)
.collect(),
info.invalid_appointments
.iter()
.map(|a| a.locator)
.collect(),
)
.with_status(info.status)
}
}
/// Summarized data associated with a given tower.
#[derive(Clone, Serialize, Debug, PartialEq, Eq)]
pub struct TowerInfo {
pub net_addr: String,
pub available_slots: u32,
pub subscription_start: u32,
pub subscription_expiry: u32,
pub status: TowerStatus,
#[serde(serialize_with = "crate::ser::serialize_receipts")]
pub appointments: HashMap<Locator, String>,
#[serde(serialize_with = "crate::ser::serialize_appointments")]
pub pending_appointments: Vec<Appointment>,
#[serde(serialize_with = "crate::ser::serialize_appointments")]
pub invalid_appointments: Vec<Appointment>,
#[serde(skip_serializing_if = "Option::is_none")]
pub misbehaving_proof: Option<MisbehaviorProof>,
}
impl TowerInfo {
/// Creates a new [TowerInfo] instance.
pub fn new(
net_addr: String,
available_slots: u32,
subscription_start: u32,
subscription_expiry: u32,
appointments: HashMap<Locator, String>,
pending_appointments: Vec<Appointment>,
invalid_appointments: Vec<Appointment>,
) -> Self {
Self {
net_addr,
available_slots,
subscription_start,
subscription_expiry,
status: TowerStatus::Reachable,
appointments,
pending_appointments,
invalid_appointments,
misbehaving_proof: None,
}
}
/// Creates a new instance using the existing info but updating the status.
pub fn with_status(mut self, status: TowerStatus) -> Self {
self.status = status;
self
}
/// Sets the misbehaving proof of a tower.
pub fn set_misbehaving_proof(&mut self, proof: MisbehaviorProof) {
self.misbehaving_proof = Some(proof);
}
}
/// A misbehaving proof. Contains proof of a tower replying with a public key different from the advertised one.
#[derive(Clone, Serialize, Debug, PartialEq, Eq)]
pub struct MisbehaviorProof {
#[serde(with = "hex::serde")]
pub locator: Locator,
pub appointment_receipt: AppointmentReceipt,
pub recovered_id: TowerId,
}
impl MisbehaviorProof {
/// Creates a new [MisbehavingProof] instance.
pub fn new(
locator: Locator,
appointment_receipt: AppointmentReceipt,
recovered_id: TowerId,
) -> Self {
Self {
locator,
appointment_receipt,
recovered_id,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const STATUSES: [TowerStatus; 5] = [
TowerStatus::Reachable,
TowerStatus::TemporaryUnreachable,
TowerStatus::Unreachable,
TowerStatus::SubscriptionError,
TowerStatus::Misbehaving,
];
const AVAILABLE_SLOTS: u32 = 21;
const SUBSCRIPTION_START: u32 = 100;
const SUBSCRIPTION_EXPIRY: u32 = SUBSCRIPTION_START + 42;
mod tower_status {
use super::*;
use TowerStatus::*;
#[test]
fn test_is_reachable() {
for status in STATUSES {
if status == Reachable {
assert!(status.is_reachable())
} else {
assert!(!status.is_reachable());
}
}
}
#[test]
fn test_is_temporary_reachable() {
for status in STATUSES {
if status == TemporaryUnreachable {
assert!(status.is_temporary_unreachable())
} else {
assert!(!status.is_temporary_unreachable());
}
}
}
#[test]
fn test_is_unreachable() {
for status in STATUSES {
if status == Unreachable {
assert!(status.is_unreachable())
} else {
assert!(!status.is_unreachable());
}
}
}
#[test]
fn test_is_misbehaving() {
for status in STATUSES {
if status == Misbehaving {
assert!(status.is_misbehaving())
} else {
assert!(!status.is_misbehaving());
}
}
}
#[test]
fn test_is_subscription_error() {
for status in STATUSES {
if status == SubscriptionError {
assert!(status.is_subscription_error())
} else {
assert!(!status.is_subscription_error());
}
}
}
#[test]
fn test_is_retryable() {
for status in STATUSES {
if status == Unreachable || status == SubscriptionError {
assert!(status.is_retryable())
} else {
assert!(!status.is_retryable());
}
}
}
}
mod tower_summary {
use super::*;
use std::iter::FromIterator;
use teos_common::test_utils::generate_random_appointment;
impl TowerSummary {
pub fn set_net_addr(&mut self, net_addr: String) {
self.net_addr = NetAddr::new(net_addr);
}
}
#[test]
fn test_new() {
let net_addr: String = "addr".to_owned();
let tower_summary = TowerSummary::new(
net_addr.clone(),
AVAILABLE_SLOTS,
SUBSCRIPTION_START,
SUBSCRIPTION_EXPIRY,
);
assert_eq!(
tower_summary,
TowerSummary {
net_addr: NetAddr::new(net_addr),
available_slots: AVAILABLE_SLOTS,
subscription_start: SUBSCRIPTION_START,
subscription_expiry: SUBSCRIPTION_EXPIRY,
status: TowerStatus::Reachable,
pending_appointments: HashSet::new(),
invalid_appointments: HashSet::new(),
},
);
}
#[test]
fn test_with_appointments() {
let net_addr: String = "addr".to_owned();
let pending_appointments =
HashSet::from_iter([generate_random_appointment(None).locator]);
let invalid_appointments =
HashSet::from_iter([generate_random_appointment(None).locator]);
let tower_summary = TowerSummary::with_appointments(
net_addr.clone(),
AVAILABLE_SLOTS,
SUBSCRIPTION_START,
SUBSCRIPTION_EXPIRY,
pending_appointments.clone(),
invalid_appointments.clone(),
);
assert_eq!(
tower_summary,
TowerSummary {
net_addr: NetAddr::new(net_addr),
available_slots: AVAILABLE_SLOTS,
subscription_start: SUBSCRIPTION_START,
subscription_expiry: SUBSCRIPTION_EXPIRY,
status: TowerStatus::Reachable,
pending_appointments,
invalid_appointments,
},
);
}
#[test]
fn test_with_status() {
let mut tower_summary = TowerSummary::new(
"addr".to_owned(),
AVAILABLE_SLOTS,
SUBSCRIPTION_START,
SUBSCRIPTION_EXPIRY,
);
let unreachable_tower = tower_summary.clone().with_status(TowerStatus::Unreachable);
tower_summary.status = TowerStatus::Unreachable;
assert_eq!(unreachable_tower, tower_summary);
}
}
mod tower_info {
use super::*;
use teos_common::test_utils::{generate_random_appointment, get_random_user_id};
impl TowerInfo {
pub fn empty(
net_addr: String,
available_slots: u32,
subscription_start: u32,
subscription_expiry: u32,
) -> Self {
TowerInfo::new(
net_addr,
available_slots,
subscription_start,
subscription_expiry,
HashMap::new(),
Vec::new(),
Vec::new(),
)
}
}
#[test]
fn test_new() {
let tower_info = TowerInfo::new(
"addr".to_owned(),
AVAILABLE_SLOTS,
SUBSCRIPTION_START,
SUBSCRIPTION_EXPIRY,
HashMap::new(),
Vec::new(),
Vec::new(),
);
assert!(tower_info.status.is_reachable());
assert!(tower_info.misbehaving_proof.is_none());
}
#[test]
fn test_with_status() {
let mut tower_info = TowerInfo::empty(
"addr".to_owned(),
AVAILABLE_SLOTS,
SUBSCRIPTION_START,
SUBSCRIPTION_EXPIRY,
);
let unreachable_tower = tower_info.clone().with_status(TowerStatus::Unreachable);
tower_info.status = TowerStatus::Unreachable;
assert_eq!(unreachable_tower, tower_info);
}
#[test]
fn test_set_misbehaving_proof() {
let mut tower_info = TowerInfo::empty(
"addr".to_owned(),
AVAILABLE_SLOTS,
SUBSCRIPTION_START,
SUBSCRIPTION_EXPIRY,
);
assert_eq!(tower_info.misbehaving_proof, None);
let appointment_receipt = AppointmentReceipt::with_signature(
"user_signature".to_owned(),
SUBSCRIPTION_START + 1,
"tower_signature".to_owned(),
);
let proof = MisbehaviorProof::new(
generate_random_appointment(None).locator,
appointment_receipt,
get_random_user_id(),
);
tower_info.set_misbehaving_proof(proof.clone());
assert_eq!(tower_info.misbehaving_proof, Some(proof));
}
}
}

670
watchtower-plugin/src/main.rs Executable file
View file

@ -0,0 +1,670 @@
use std::convert::TryFrom;
use std::env;
use std::path::PathBuf;
use std::sync::{Arc, Mutex, MutexGuard};
use home::home_dir;
use serde_json::json;
use tokio::io::{stdin, stdout};
use tokio::sync::mpsc::unbounded_channel;
use cln_plugin::options::config_type::DefaultInteger;
use cln_plugin::options::ConfigOption;
use cln_plugin::{anyhow, Builder, Error, Plugin};
use teos_common::appointment::{Appointment, Locator};
use teos_common::net::http::Endpoint;
use teos_common::net::NetAddr;
use teos_common::protos as common_msgs;
use teos_common::TowerId;
use teos_common::{cryptography, errors};
use watchtower_plugin::convert::{CommitmentRevocation, GetAppointmentParams, RegisterParams};
use watchtower_plugin::net::http::{
self, get_request, post_request, process_post_response, AddAppointmentError, ApiResponse,
RequestError,
};
use watchtower_plugin::net::ProxyInfo;
use watchtower_plugin::retrier::RetryManager;
use watchtower_plugin::wt_client::{RevocationData, WTClient};
use watchtower_plugin::{constants, TowerStatus};
const DEV_WT_MAX_RETRY_INTERVAL_CONFIG: ConfigOption<DefaultInteger> =
ConfigOption::new_i64_with_default(
constants::DEV_WT_MAX_RETRY_INTERVAL,
constants::DEFAULT_DEV_WT_MAX_RETRY_INTERVAL,
constants::DEV_WT_MAX_RETRY_INTERVAL_DESC,
);
const WT_AUTO_RETRY_DELAY_CONFIG: ConfigOption<DefaultInteger> = ConfigOption::new_i64_with_default(
constants::WT_AUTO_RETRY_DELAY,
constants::DEFAULT_WT_AUTO_RETRY_DELAY,
constants::WT_AUTO_RETRY_DELAY_DESC,
);
const WT_MAX_RETRY_TIME_CONFIG: ConfigOption<DefaultInteger> = ConfigOption::new_i64_with_default(
constants::WT_MAX_RETRY_TIME,
constants::DEFAULT_WT_MAX_RETRY_TIME,
constants::WT_MAX_RETRY_TIME_DESC,
);
const WT_PORT_CONFG: ConfigOption<DefaultInteger> = ConfigOption::new_i64_with_default(
constants::WT_PORT,
constants::DEFAULT_WT_PORT,
constants::WT_PORT_DESC,
);
fn to_cln_error(e: RequestError) -> Error {
let e = match e {
RequestError::ConnectionError(e) => anyhow!(e),
RequestError::DeserializeError(e) => anyhow!(e),
RequestError::Unexpected(e) => anyhow!(e),
};
log::info!("{e}");
e
}
/// Sends fresh data to a retrier as long as is does not exist, or it does and its running.
fn send_to_retrier(state: &MutexGuard<WTClient>, tower_id: TowerId, locator: Locator) {
if if let Some(status) = state.get_retrier_status(&tower_id) {
// A retrier in the retriers map can only be running or idle
status.is_running()
} else {
true
} {
state
.unreachable_towers
.send((tower_id, RevocationData::Fresh(locator)))
.unwrap();
} else {
log::debug!("Not sending data to idle retrier ({tower_id}, {locator})")
}
}
/// Registers the client to a given tower.
///
/// Accepted tower_id formats:
/// - tower_id@host:port
/// - tower_id host port
/// - tower_id@host (will default port to DEFAULT_PORT)
/// - tower_id host (will default port to DEFAULT_PORT)
async fn register(
plugin: Plugin<Arc<Mutex<WTClient>>>,
v: serde_json::Value,
) -> Result<serde_json::Value, Error> {
let params = RegisterParams::try_from(v).map_err(|x| anyhow!(x))?;
let mut host = params.host.unwrap_or_else(|| "localhost".to_owned());
let tower_id = params.tower_id;
let user_id = plugin.state().lock().unwrap().user_id;
// TODO: The user should pick the start_time or, at least, check the returned start time against it's known block height.
// Otherwise the tower could just generate a subscription starting far in the future. For this we need to access lightning RPC
// which is not available in the current version of `cln-plugin` (but already on master). Add it for the next release.
let port = params.port.unwrap_or(
u16::try_from(plugin.option(&WT_PORT_CONFG).unwrap())
.map_err(|_| anyhow!("{} out of range", constants::WT_PORT))?,
);
let tower_net_addr = {
if !host.starts_with("http://") && !host.starts_with("https://") {
host = format!("http://{host}")
}
NetAddr::new(format!("{host}:{port}"))
};
let proxy = plugin.state().lock().unwrap().proxy.clone();
let receipt = http::register(tower_id, user_id, &tower_net_addr, &proxy)
.await
.map_err(|e| {
let mut state = plugin.state().lock().unwrap();
if e.is_connection() && state.towers.contains_key(&tower_id) {
state.set_tower_status(tower_id, TowerStatus::TemporaryUnreachable);
}
to_cln_error(e)
})?;
if !receipt.verify(&tower_id) {
return Err(anyhow!(
"Registration receipt contains bad signature. Are you using the right tower_id?"
));
}
plugin
.state()
.lock()
.unwrap()
.add_update_tower(tower_id, tower_net_addr.net_addr(), &receipt).map_err(|e| {
if e.is_expiry() {
anyhow!("Registration receipt contains a subscription expiry that is not higher than the one we are currently registered for")
} else {
anyhow!("Registration receipt does not contain more slots than the ones we are currently registered for")
}
})?;
log::info!(
"Registration succeeded. Available slots: {}. Subscription period (block height range): ({}-{})",
receipt.available_slots(),
receipt.subscription_start(),
receipt.subscription_expiry()
);
Ok(json!(receipt))
}
/// Gets the latest registration receipt from the client to a given tower (if it exists).
///
/// This is pulled from the database
async fn get_registration_receipt(
plugin: Plugin<Arc<Mutex<WTClient>>>,
v: serde_json::Value,
) -> Result<serde_json::Value, Error> {
let tower_id = TowerId::try_from(v).map_err(|x| anyhow!(x))?;
let state = plugin.state().lock().unwrap();
if let Some(response) = state.get_registration_receipt(tower_id) {
Ok(json!(response))
} else {
Err(anyhow!(
"Cannot find {tower_id} within the known towers. Have you registered?"
))
}
}
/// Gets the subscription information directly form the tower.
async fn get_subscription_info(
plugin: Plugin<Arc<Mutex<WTClient>>>,
v: serde_json::Value,
) -> Result<serde_json::Value, Error> {
let tower_id = TowerId::try_from(v).map_err(|x| anyhow!(x))?;
let (user_sk, tower_net_addr, proxy) = {
let state = plugin.state().lock().unwrap();
if let Some(info) = state.towers.get(&tower_id) {
Ok((state.user_sk, info.net_addr.clone(), state.proxy.clone()))
} else {
Err(anyhow!("Unknown tower id: {tower_id}"))
}
}?;
let signature = cryptography::sign("get subscription info".as_bytes(), &user_sk);
let response: common_msgs::GetSubscriptionInfoResponse = process_post_response(
post_request(
&tower_net_addr,
Endpoint::GetSubscriptionInfo,
&common_msgs::GetSubscriptionInfoRequest { signature },
&proxy,
)
.await,
)
.await
.map_err(|e| {
if e.is_connection() {
plugin
.state()
.lock()
.unwrap()
.set_tower_status(tower_id, TowerStatus::TemporaryUnreachable);
}
to_cln_error(e)
})?;
Ok(json!(response))
}
/// Gets information about an appointment from the tower.
async fn get_appointment(
plugin: Plugin<Arc<Mutex<WTClient>>>,
v: serde_json::Value,
) -> Result<serde_json::Value, Error> {
let params = GetAppointmentParams::try_from(v).map_err(|x| anyhow!(x))?;
let (user_sk, tower_net_addr, proxy) = {
let state = plugin.state().lock().unwrap();
if let Some(info) = state.towers.get(&params.tower_id) {
Ok((state.user_sk, info.net_addr.clone(), state.proxy.clone()))
} else {
Err(anyhow!("Unknown tower id: {}", params.tower_id))
}
}?;
let signature = cryptography::sign(
format!("get appointment {}", params.locator).as_bytes(),
&user_sk,
);
let response: ApiResponse<common_msgs::GetAppointmentResponse> = process_post_response(
post_request(
&tower_net_addr,
Endpoint::GetAppointment,
&common_msgs::GetAppointmentRequest {
locator: params.locator.to_vec(),
signature,
},
&proxy,
)
.await,
)
.await
.map_err(|e| {
if e.is_connection() {
plugin
.state()
.lock()
.unwrap()
.set_tower_status(params.tower_id, TowerStatus::TemporaryUnreachable);
}
to_cln_error(e)
})?;
Ok(json!(response))
}
/// Gets an appointment receipt from the client given a tower_id and a locator (if it exists).
///
/// This is pulled from the database
async fn get_appointment_receipt(
plugin: Plugin<Arc<Mutex<WTClient>>>,
v: serde_json::Value,
) -> Result<serde_json::Value, Error> {
let params = GetAppointmentParams::try_from(v).map_err(|x| anyhow!(x))?;
let state = plugin.state().lock().unwrap();
if let Some(r) = state.get_appointment_receipt(params.tower_id, params.locator) {
Ok(json!(r))
} else if state.towers.contains_key(&params.tower_id) {
Err(anyhow!(
"Cannot find {} within {}. Did you send that appointment?",
params.locator,
params.tower_id
))
} else {
Err(anyhow!(
"Cannot find {} within the known towers. Have you registered?",
params.tower_id
))
}
}
/// Lists all the registered towers.
///
/// The given information comes from memory, so it is summarized.
async fn list_towers(
plugin: Plugin<Arc<Mutex<WTClient>>>,
_: serde_json::Value,
) -> Result<serde_json::Value, Error> {
Ok(json!(plugin.state().lock().unwrap().towers))
}
/// Gets information about a given tower.
///
/// Data comes from disk (DB), so all stored data is provided.
async fn get_tower_info(
plugin: Plugin<Arc<Mutex<WTClient>>>,
v: serde_json::Value,
) -> Result<serde_json::Value, Error> {
let state = plugin.state().lock().unwrap();
let tower_id = TowerId::try_from(v).map_err(|e| anyhow!(e))?;
if let Some(tower_info) = state.load_tower_info(tower_id) {
// Notice we need to check the status in memory since we cannot distinguish between unreachable and temporary unreachable
// by just checking the data in the database.
Ok(json!(
tower_info.with_status(state.get_tower_status(&tower_id).unwrap())
))
} else {
Err(anyhow!(
"Cannot find {tower_id} within the known towers. Have you registered?",
))
}
}
async fn ping(
plugin: Plugin<Arc<Mutex<WTClient>>>,
v: serde_json::Value,
) -> Result<serde_json::Value, Error> {
let (tower_net_addr, proxy) = {
// Check if the tower_id is known to the plugin
let tower_id = TowerId::try_from(v).map_err(|e| anyhow!(e))?;
let state = plugin.state().lock().unwrap();
(
state
.towers
.get(&tower_id)
.ok_or(anyhow!("Unknown tower_id"))?
.net_addr
.clone(),
state.proxy.clone(),
)
};
let response = get_request(&tower_net_addr, Endpoint::Ping, &proxy)
.await
.map_err(to_cln_error)?;
if response.status().is_success() {
Ok(json!("Tower is reachable"))
} else {
Err(anyhow!(format!(
"Tower cannot be reached (Error: {})",
response.status()
)))
}
}
/// Triggers a manual retry of a tower, tries to send all pending appointments to it.
///
/// Only works if the tower is unreachable or there's been a subscription error (and the tower is not already being retried).
async fn retry_tower(
plugin: Plugin<Arc<Mutex<WTClient>>>,
v: serde_json::Value,
) -> Result<serde_json::Value, Error> {
let tower_id = TowerId::try_from(v).map_err(|e| anyhow!(e))?;
let state = plugin.state().lock().unwrap();
if let Some(tower_status) = state.get_tower_status(&tower_id) {
if let Some(retrier_status) = state.retriers.get(&tower_id) {
if retrier_status.is_idle() {
// We don't send any associated data in this case given the idle retrier already has it all.
state
.unreachable_towers
.send((tower_id, RevocationData::None))
.map_err(|e| anyhow!(e))?;
} else {
// Status can only be running or idle for data in the retriers map.
return Err(anyhow!("{tower_id} is already being retried"));
}
} else if tower_status.is_retryable() {
// We do send associated data here given there is no retrier associated to this tower.
state
.unreachable_towers
.send((
tower_id,
RevocationData::Stale(
state
.towers
.get(&tower_id)
.unwrap()
.pending_appointments
.iter()
.cloned()
.collect(),
),
))
.map_err(|e| anyhow!(e))?;
} else {
return Err(anyhow!(
"Tower status must be unreachable or have a subscription issue to manually retry",
));
}
} else {
return Err(anyhow!("Unknown tower {tower_id}"));
}
Ok(json!(format!("Retrying {tower_id}")))
}
/// Forgets about a tower wiping out all local data associated to it.
async fn abandon_tower(
plugin: Plugin<Arc<Mutex<WTClient>>>,
v: serde_json::Value,
) -> Result<serde_json::Value, Error> {
let tower_id = TowerId::try_from(v).map_err(|e| anyhow!(e))?;
let mut state = plugin.state().lock().unwrap();
if state.towers.contains_key(&tower_id) {
state.remove_tower(tower_id).unwrap();
Ok(json!(format!("{tower_id} successfully abandoned")))
} else {
Err(anyhow!("Unknown tower {tower_id}"))
}
}
/// Sends an appointment to all registered towers for every new commitment transaction.
///
/// The appointment is built using the data provided by the backend (dispute txid and penalty transaction).
async fn on_commitment_revocation(
plugin: Plugin<Arc<Mutex<WTClient>>>,
v: serde_json::Value,
) -> Result<serde_json::Value, Error> {
let commitment_revocation = serde_json::from_value::<CommitmentRevocation>(v)
.map_err(|e| anyhow!("Cannot decode commitment_revocation data. Error: {e}"))?;
log::debug!(
"New commitment revocation received for channel {}. Commit number {}",
commitment_revocation.channel_id,
commitment_revocation.commit_num
);
// TODO: For now, to_self_delay is hardcoded to 42. Revisit and define it better / remove it when / if needed
let locator = Locator::new(commitment_revocation.commitment_txid);
let appointment = Appointment::new(
locator,
cryptography::encrypt(
&commitment_revocation.penalty_tx,
&commitment_revocation.commitment_txid,
)
.unwrap(),
42,
);
let signature = cryptography::sign(
&appointment.to_vec(),
&plugin.state().lock().unwrap().user_sk,
);
// Looks like we cannot iterate through towers given a locked state is not Send (due to the async call),
// so we need to clone the bare minimum.
let towers = plugin
.state()
.lock()
.unwrap()
.towers
.iter()
.map(|(id, info)| (*id, info.net_addr.clone(), info.status))
.collect::<Vec<_>>();
let proxy = plugin.state().lock().unwrap().proxy.clone();
for (tower_id, net_addr, status) in towers {
if status.is_reachable() {
match http::add_appointment(tower_id, &net_addr, &proxy, &appointment, &signature).await
{
Ok((slots, receipt)) => {
plugin
.state()
.lock()
.unwrap()
.add_appointment_receipt(tower_id, locator, slots, &receipt);
log::debug!("Response verified and data stored in the database");
}
Err(e) => match e {
AddAppointmentError::RequestError(e) => {
if e.is_connection() {
log::warn!(
"{tower_id} cannot be reached. Adding {} to pending appointments",
appointment.locator
);
let mut state = plugin.state().lock().unwrap();
state.set_tower_status(tower_id, TowerStatus::TemporaryUnreachable);
state.add_pending_appointment(tower_id, &appointment);
send_to_retrier(&state, tower_id, appointment.locator);
}
}
AddAppointmentError::ApiError(e) => match e.error_code {
errors::INVALID_SIGNATURE_OR_SUBSCRIPTION_ERROR => {
log::warn!(
"There is a subscription issue with {tower_id}. Adding {} to pending",
appointment.locator
);
let mut state = plugin.state().lock().unwrap();
state.set_tower_status(tower_id, TowerStatus::SubscriptionError);
state.add_pending_appointment(tower_id, &appointment);
send_to_retrier(&state, tower_id, appointment.locator);
}
_ => {
log::warn!(
"{tower_id} rejected the appointment. Error: {}, error_code: {}",
e.error,
e.error_code
);
plugin
.state()
.lock()
.unwrap()
.add_invalid_appointment(tower_id, &appointment);
}
},
AddAppointmentError::SignatureError(proof) => {
log::warn!("Cannot recover known tower_id from the appointment receipt. Flagging tower as misbehaving");
plugin
.state()
.lock()
.unwrap()
.flag_misbehaving_tower(tower_id, proof)
}
},
};
} else if status.is_misbehaving() {
log::warn!("{tower_id} is misbehaving. Not sending any further appointments",);
} else {
if status.is_subscription_error() {
log::warn!(
"There is a subscription issue with {tower_id}. Adding {} to pending",
appointment.locator
);
} else {
log::warn!(
"{tower_id} is {status}. Adding {} to pending",
appointment.locator,
);
}
let mut state = plugin.state().lock().unwrap();
state.add_pending_appointment(tower_id, &appointment);
if !status.is_unreachable() {
send_to_retrier(&state, tower_id, appointment.locator);
}
}
}
// FIXME: Ask cdecker: Do hooks need to return something?
Ok(json!(r#" {"result": continue}"#))
}
#[tokio::main]
async fn main() -> Result<(), Error> {
let data_dir = match env::var(constants::TOWERS_DATA_DIR) {
Ok(v) => PathBuf::from(v),
Err(_) => home_dir().unwrap().join(constants::DEFAULT_TOWERS_DATA_DIR),
};
let builder = Builder::new(stdin(), stdout())
.option(WT_PORT_CONFG)
.option(WT_MAX_RETRY_TIME_CONFIG)
.option(WT_AUTO_RETRY_DELAY_CONFIG)
.option(DEV_WT_MAX_RETRY_INTERVAL_CONFIG)
.rpcmethod(
constants::RPC_REGISTER_TOWER,
constants::RPC_REGISTER_TOWER_DESC,
register,
)
.rpcmethod(
constants::RPC_GET_REGISTRATION_RECEIPT,
constants::RPC_GET_REGISTRATION_RECEIPT_DESC,
get_registration_receipt,
)
.rpcmethod(
constants::RPC_GET_APPOINTMENT,
constants::RPC_GET_APPOINTMENT_DESC,
get_appointment,
)
.rpcmethod(
constants::RPC_GET_APPOINTMENT_RECEIPT,
constants::RPC_GET_APPOINTMENT_RECEIPT_DESC,
get_appointment_receipt,
)
.rpcmethod(
constants::RPC_GET_SUBSCRIPTION_INFO,
constants::RPC_GET_SUBSCRIPTION_INFO_DESC,
get_subscription_info,
)
.rpcmethod(
constants::RPC_LIST_TOWERS,
constants::RPC_LIST_TOWERS_DESC,
list_towers,
)
.rpcmethod(
constants::RPC_GET_TOWER_INFO,
constants::RPC_GET_TOWER_INFO_DESC,
get_tower_info,
)
.rpcmethod(constants::RPC_PING, constants::RPC_PING_DESC, ping)
.rpcmethod(
constants::RPC_RETRY_TOWER,
constants::RPC_RETRY_TOWER_DESC,
retry_tower,
)
.rpcmethod(
constants::RPC_ABANDON_TOWER,
constants::RPC_ABANDON_TOWER_DESC,
abandon_tower,
)
.hook(
constants::HOOK_COMMITMENT_REVOCATION,
on_commitment_revocation,
);
// We're unwrapping here given it does not seem we actually have anything to check at the moment.
// Change this so the plugin can be disabled soon if this happens not to be the case.
let midstate = if let Some(midstate) = builder.configure().await? {
midstate
} else {
return Ok(());
};
let (tx, rx) = unbounded_channel();
let wt_client = Arc::new(Mutex::new(
WTClient::with_proxy(
data_dir,
tx,
midstate.configuration().proxy.map(|proxy| {
// We don't need to inform `always-use-proxy` needing `proxy` to work. This is done by CLN already when needed.
ProxyInfo::new(
proxy,
midstate.configuration().always_use_proxy.unwrap_or(false),
)
}),
)
.await,
));
let max_elapsed_time = u16::try_from(midstate.option(&WT_MAX_RETRY_TIME_CONFIG).unwrap())
.inspect_err(|_| {
log::error!("{} out of range", constants::WT_MAX_RETRY_TIME);
})?;
let auto_retry_delay = u32::try_from(midstate.option(&WT_AUTO_RETRY_DELAY_CONFIG).unwrap())
.inspect_err(|_| {
log::error!("{} out of range", constants::WT_AUTO_RETRY_DELAY);
})?;
let max_interval_time = u16::try_from(
midstate.option(&DEV_WT_MAX_RETRY_INTERVAL_CONFIG).unwrap(),
)
.inspect_err(|_| {
log::error!("{} out of range", constants::DEV_WT_MAX_RETRY_INTERVAL);
})?;
let plugin = midstate.start(wt_client.clone()).await?;
tokio::spawn(async move {
RetryManager::new(
wt_client,
rx,
max_elapsed_time,
auto_retry_delay,
max_interval_time,
)
.manage_retry()
.await
});
plugin.join().await
}

View file

@ -0,0 +1,687 @@
use reqwest::{Method, Response};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use teos_common::appointment::Appointment;
use teos_common::cryptography;
use teos_common::net::http::Endpoint;
use teos_common::net::NetAddr;
use teos_common::protos as common_msgs;
use teos_common::receipts::{AppointmentReceipt, RegistrationReceipt};
use teos_common::{TowerId, UserId};
use crate::net::ProxyInfo;
use crate::MisbehaviorProof;
/// Represents a generic api response.
#[derive(Serialize, Deserialize, Debug)]
#[serde(untagged)]
pub enum ApiResponse<T> {
Response(T),
Error(ApiError),
}
/// API errors that can be received when interacting with the tower. Error codes match `teos_common::errors`.
#[derive(Serialize, Deserialize, Debug)]
pub struct ApiError {
pub error: String,
pub error_code: u8,
}
/// Errors related to requests sent to the tower.
#[derive(Debug, PartialEq, Eq)]
pub enum RequestError {
ConnectionError(String),
DeserializeError(String),
Unexpected(String),
}
impl RequestError {
pub fn is_connection(&self) -> bool {
matches!(self, RequestError::ConnectionError(_))
}
}
/// Errors related to the `add_appointment` requests to the tower.
#[derive(Debug)]
pub enum AddAppointmentError {
RequestError(RequestError),
ApiError(ApiError),
SignatureError(MisbehaviorProof),
}
impl From<RequestError> for AddAppointmentError {
fn from(r: RequestError) -> Self {
AddAppointmentError::RequestError(r)
}
}
/// Handles the logic of interacting with the `register` endpoint of the tower.
pub async fn register(
tower_id: TowerId,
user_id: UserId,
tower_net_addr: &NetAddr,
proxy: &Option<ProxyInfo>,
) -> Result<RegistrationReceipt, RequestError> {
log::info!("Registering in the Eye of Satoshi (tower_id={tower_id})");
process_post_response(
post_request(
tower_net_addr,
Endpoint::Register,
&common_msgs::RegisterRequest {
user_id: user_id.to_vec(),
},
proxy,
)
.await,
)
.await
.map(|r: common_msgs::RegisterResponse| {
RegistrationReceipt::with_signature(
user_id,
r.available_slots,
r.subscription_start,
r.subscription_expiry,
r.subscription_signature,
)
})
}
/// Encapsulates the logging and response parsing of sending and appointment to the tower.
pub async fn add_appointment(
tower_id: TowerId,
tower_net_addr: &NetAddr,
proxy: &Option<ProxyInfo>,
appointment: &Appointment,
signature: &str,
) -> Result<(u32, AppointmentReceipt), AddAppointmentError> {
log::debug!(
"Sending appointment {} to tower {tower_id}",
appointment.locator
);
let (response, receipt) =
send_appointment(tower_id, tower_net_addr, proxy, appointment, signature).await?;
log::debug!("Appointment accepted and signed by {tower_id}");
log::debug!("Remaining slots: {}", response.available_slots);
log::debug!("Start block: {}", response.start_block);
Ok((response.available_slots, receipt))
}
/// Handles the logic of interacting with the `add_appointment` endpoint of the tower.
pub async fn send_appointment(
tower_id: TowerId,
tower_net_addr: &NetAddr,
proxy: &Option<ProxyInfo>,
appointment: &Appointment,
signature: &str,
) -> Result<(common_msgs::AddAppointmentResponse, AppointmentReceipt), AddAppointmentError> {
let request_data = common_msgs::AddAppointmentRequest {
appointment: Some(appointment.clone().into()),
signature: signature.to_owned(),
};
match process_post_response(
post_request(
tower_net_addr,
Endpoint::AddAppointment,
&request_data,
proxy,
)
.await,
)
.await?
{
ApiResponse::Response::<common_msgs::AddAppointmentResponse>(r) => {
let receipt = AppointmentReceipt::with_signature(
signature.to_owned(),
r.start_block,
r.signature.clone(),
);
let recovered_id = TowerId(
cryptography::recover_pk(&receipt.to_vec(), &receipt.signature().unwrap()).unwrap(),
);
if recovered_id == tower_id {
Ok((r, receipt))
} else {
Err(AddAppointmentError::SignatureError(MisbehaviorProof::new(
appointment.locator,
receipt,
recovered_id,
)))
}
}
ApiResponse::Error(e) => Err(AddAppointmentError::ApiError(e)),
}
}
/// A generic function to send a request to a tower.
async fn request<S: Serialize>(
tower_net_addr: &NetAddr,
endpoint: Endpoint,
proxy: &Option<ProxyInfo>,
method: Method,
data: Option<S>,
) -> Result<Response, RequestError> {
let client = if let Some(proxy) = proxy {
if proxy.always_use || tower_net_addr.is_onion() {
reqwest::Client::builder()
.proxy(
reqwest::Proxy::http(proxy.get_socks_addr())
.map_err(|e| RequestError::ConnectionError(format!("{e}")))?,
)
.build()
.map_err(|e| RequestError::ConnectionError(format!("{e}")))?
} else {
reqwest::Client::new()
}
} else {
// If there is no proxy we only build the client as long as the address is not onion
if tower_net_addr.is_onion() {
return Err(RequestError::ConnectionError(
"Cannot connect to an onion address without a proxy".to_owned(),
));
}
reqwest::Client::new()
};
let mut request_builder = client.request(
method,
format!("{}{}", tower_net_addr.net_addr(), endpoint.path()),
);
if let Some(data) = data {
request_builder = request_builder.json(&data);
}
request_builder.send().await.map_err(|e| {
log::debug!("An error ocurred when sending data to the tower: {e}");
if e.is_connect() | e.is_timeout() {
RequestError::ConnectionError(
"Cannot connect to the tower. Connection refused".to_owned(),
)
} else {
RequestError::Unexpected("Unexpected error ocurred (see logs for more info)".to_owned())
}
})
}
pub async fn post_request<S: Serialize>(
tower_net_addr: &NetAddr,
endpoint: Endpoint,
data: S,
proxy: &Option<ProxyInfo>,
) -> Result<Response, RequestError> {
request(tower_net_addr, endpoint, proxy, Method::POST, Some(data)).await
}
pub async fn get_request(
tower_net_addr: &NetAddr,
endpoint: Endpoint,
proxy: &Option<ProxyInfo>,
) -> Result<Response, RequestError> {
request::<()>(tower_net_addr, endpoint, proxy, Method::GET, None).await
}
/// Generic function to process the response of a given post request.
pub async fn process_post_response<T: DeserializeOwned>(
post_request: Result<Response, RequestError>,
) -> Result<T, RequestError> {
// TODO: Check if this can be switched for a map. Not sure how to handle async with maps
match post_request {
Ok(r) => r.json().await.map_err(|e| {
RequestError::DeserializeError(format!("Unexpected response body. Error: {e}"))
}),
Err(e) => Err(e),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use crate::test_utils::get_dummy_add_appointment_response;
use teos_common::test_utils::{
generate_random_appointment, get_random_appointment_receipt,
get_random_registration_receipt, get_random_user_id,
};
mod request_error {
use super::*;
#[test]
fn test_is_connection() {
let error_message = "error_msg";
for error in [
RequestError::ConnectionError(error_message.to_owned()),
RequestError::DeserializeError(error_message.to_owned()),
RequestError::Unexpected(error_message.to_owned()),
] {
if error == RequestError::ConnectionError(error_message.to_owned()) {
assert!(error.is_connection())
} else {
assert!(!error.is_connection())
}
}
}
}
#[tokio::test]
async fn test_register() {
let (tower_sk, tower_pk) = cryptography::get_random_keypair();
let mut registration_receipt = get_random_registration_receipt();
registration_receipt.sign(&tower_sk);
let mut server = mockito::Server::new_async().await;
let api_mock = server
.mock("POST", Endpoint::Register.path().as_str())
.with_status(200)
.with_header("content-type", "application/json")
.with_body(json!(registration_receipt).to_string())
.create_async()
.await;
let receipt = register(
TowerId(tower_pk),
registration_receipt.user_id(),
&NetAddr::new(server.url()),
&None,
)
.await
.unwrap();
api_mock.assert_async().await;
assert_eq!(receipt, registration_receipt);
}
#[tokio::test]
async fn test_register_connection_error() {
let error = register(
get_random_user_id(),
get_random_user_id(),
&NetAddr::new("http://server_addr".to_owned()),
&None,
)
.await
.unwrap_err();
assert!(matches!(error, RequestError::ConnectionError { .. }))
}
#[tokio::test]
async fn test_register_deserialize_error() {
let mut server = mockito::Server::new_async().await;
let api_mock = server
.mock("POST", Endpoint::Register.path().as_str())
.with_status(200)
.with_header("content-type", "application/json")
.with_body(json!([]).to_string())
.create_async()
.await;
let error = register(
get_random_user_id(),
get_random_user_id(),
&NetAddr::new(server.url()),
&None,
)
.await
.unwrap_err();
api_mock.assert_async().await;
assert!(matches!(error, RequestError::DeserializeError { .. }))
}
#[tokio::test]
async fn test_add_appointment() {
// `add_appointment` is basically a pass trough function for `send_appointment` with some logging and a parse of the outputs
// in case there are no errors. All the error cases will be tested in `send_appointment`.
let (tower_sk, tower_pk) = cryptography::get_random_keypair();
let appointment = generate_random_appointment(None);
let appointment_receipt = get_random_appointment_receipt(tower_sk);
let add_appointment_response =
get_dummy_add_appointment_response(appointment.locator, &appointment_receipt);
let mut server = mockito::Server::new_async().await;
let api_mock = server
.mock("POST", Endpoint::AddAppointment.path().as_str())
.with_status(200)
.with_header("content-type", "application/json")
.with_body(json!(add_appointment_response).to_string())
.create_async()
.await;
let (response, receipt) = add_appointment(
TowerId(tower_pk),
&NetAddr::new(server.url()),
&None,
&appointment,
appointment_receipt.user_signature(),
)
.await
.unwrap();
api_mock.assert_async().await;
assert_eq!(response, add_appointment_response.available_slots);
assert_eq!(receipt, appointment_receipt);
}
#[tokio::test]
async fn test_send_appointment() {
let (tower_sk, tower_pk) = cryptography::get_random_keypair();
let appointment = generate_random_appointment(None);
let appointment_receipt = get_random_appointment_receipt(tower_sk);
let add_appointment_response =
get_dummy_add_appointment_response(appointment.locator, &appointment_receipt);
let mut server = mockito::Server::new_async().await;
let api_mock = server
.mock("POST", Endpoint::AddAppointment.path().as_str())
.with_status(200)
.with_header("content-type", "application/json")
.with_body(json!(add_appointment_response).to_string())
.create_async()
.await;
let (response, receipt) = send_appointment(
TowerId(tower_pk),
&NetAddr::new(server.url()),
&None,
&appointment,
appointment_receipt.user_signature(),
)
.await
.unwrap();
api_mock.assert_async().await;
assert_eq!(response, add_appointment_response);
assert_eq!(receipt, appointment_receipt);
}
#[tokio::test]
async fn test_send_appointment_misbehaving() {
let (sybil_tower_sk, sibyl_tower_pk) = cryptography::get_random_keypair();
let appointment = generate_random_appointment(None);
let appointment_receipt = get_random_appointment_receipt(sybil_tower_sk);
let add_appointment_response =
get_dummy_add_appointment_response(appointment.locator, &appointment_receipt);
let mut server = mockito::Server::new_async().await;
let api_mock = server
.mock("POST", Endpoint::AddAppointment.path().as_str())
.with_status(200)
.with_header("content-type", "application/json")
.with_body(json!(add_appointment_response).to_string())
.create_async()
.await;
let tower_id = get_random_user_id();
let error = send_appointment(
tower_id,
&NetAddr::new(server.url()),
&None,
&appointment,
appointment_receipt.user_signature(),
)
.await
.unwrap_err();
api_mock.assert_async().await;
if let AddAppointmentError::SignatureError(proof) = error {
assert_eq!(
MisbehaviorProof::new(
appointment.locator,
appointment_receipt,
TowerId(sibyl_tower_pk)
),
proof
)
} else {
panic!("SignatureError was expected")
}
}
#[tokio::test]
async fn test_send_appointment_connection_error() {
let error = send_appointment(
get_random_user_id(),
&NetAddr::new("http://server_addr".to_owned()),
&None,
&generate_random_appointment(None),
"user_sig",
)
.await
.unwrap_err();
if let AddAppointmentError::RequestError(e) = error {
assert!(matches!(e, RequestError::ConnectionError { .. }))
} else {
panic!("ConnectionError was expected")
}
}
#[tokio::test]
async fn test_send_appointment_deserialize_error() {
let mut server = mockito::Server::new_async().await;
let api_mock = server
.mock("POST", Endpoint::AddAppointment.path().as_str())
.with_status(200)
.with_header("content-type", "application/json")
.with_body(json!([]).to_string())
.create_async()
.await;
let error = send_appointment(
get_random_user_id(),
&NetAddr::new(server.url()),
&None,
&generate_random_appointment(None),
"user_sig",
)
.await
.unwrap_err();
api_mock.assert_async().await;
if let AddAppointmentError::RequestError(e) = error {
assert!(matches!(e, RequestError::DeserializeError { .. }))
} else {
panic!("DeserializeError was expected")
}
}
#[tokio::test]
async fn test_send_appointment_api_error() {
let api_error = ApiError {
error: "error_msg".to_owned(),
error_code: 1,
};
let mut server = mockito::Server::new_async().await;
let api_mock = server
.mock("POST", Endpoint::AddAppointment.path().as_str())
.with_status(400)
.with_header("content-type", "application/json")
.with_body(json!(api_error).to_string())
.create_async()
.await;
let error = send_appointment(
get_random_user_id(),
&NetAddr::new(server.url()),
&None,
&generate_random_appointment(None),
"user_sig",
)
.await
.unwrap_err();
api_mock.assert_async().await;
assert!(matches!(error, AddAppointmentError::ApiError { .. }));
}
#[tokio::test]
async fn test_request() {
let mut server = mockito::Server::new_async().await;
// Test with POST
let api_mock_post = server
.mock("POST", Endpoint::Register.path().as_str())
.with_status(200)
.with_header("content-type", "application/json")
.create_async()
.await;
let response_post = request(
&NetAddr::new(server.url()),
Endpoint::Register,
&None,
Method::POST,
Some(json!("")),
)
.await;
api_mock_post.assert_async().await;
assert!(matches!(response_post, Ok(Response { .. })));
// Test with GET
let api_mock_get = server
.mock("GET", Endpoint::Ping.path().as_str())
.with_status(200)
.with_header("content-type", "application/json")
.create_async()
.await;
let response_get = request::<()>(
&NetAddr::new(server.url()),
Endpoint::Ping,
&None,
Method::GET,
None,
)
.await;
api_mock_get.assert_async().await;
assert!(matches!(response_get, Ok(Response { .. })));
}
#[tokio::test]
async fn test_request_connection_error() {
assert!(request(
&NetAddr::new("http://unreachable_url".to_owned()),
Endpoint::Register,
&None,
Method::POST,
Some(json!("")),
)
.await
.unwrap_err()
.is_connection());
assert!(request(
&NetAddr::new("http://unreachable_url".to_owned()),
Endpoint::Ping,
&None,
Method::GET,
None::<&str>,
)
.await
.unwrap_err()
.is_connection());
}
#[tokio::test]
async fn test_get_request() {
let mut server = mockito::Server::new_async().await;
let api_mock = server
.mock("GET", Endpoint::Ping.path().as_str())
.with_status(200)
.with_header("content-type", "application/json")
.create_async()
.await;
let response = get_request(&NetAddr::new(server.url()), Endpoint::Ping, &None).await;
api_mock.assert_async().await;
assert!(matches!(response, Ok(Response { .. })));
}
#[tokio::test]
async fn test_get_request_connection_error() {
assert!(get_request(
&NetAddr::new("http://unreachable_url".to_owned()),
Endpoint::Ping,
&None,
)
.await
.unwrap_err()
.is_connection());
}
#[tokio::test]
async fn test_post_request() {
let mut server = mockito::Server::new_async().await;
let api_mock = server
.mock("POST", Endpoint::Register.path().as_str())
.with_status(200)
.with_header("content-type", "application/json")
.create_async()
.await;
let response = post_request(
&NetAddr::new(server.url()),
Endpoint::Register,
json!(""),
&None,
)
.await;
api_mock.assert_async().await;
assert!(matches!(response, Ok(Response { .. })));
}
#[tokio::test]
async fn test_post_request_connection_error() {
assert!(post_request(
&NetAddr::new("http://unreachable_url".to_owned()),
Endpoint::Register,
json!(""),
&None,
)
.await
.unwrap_err()
.is_connection());
}
#[tokio::test]
async fn test_process_post_response_json_error() {
// `process_post_response` is a pass-trough function that maps json deserialization errors from `post_request`.
// So just testing that specific case should be enough.
let mut server = mockito::Server::new_async().await;
let api_mock = server
.mock("POST", Endpoint::GetAppointment.path().as_str())
.with_status(200)
.with_header("content-type", "application/json")
.create_async()
.await;
// Any expected response work here as long as it cannot be properly deserialized
let error = process_post_response::<ApiResponse<common_msgs::GetAppointmentResponse>>(
post_request(
&NetAddr::new(server.url()),
Endpoint::GetAppointment,
json!(""),
&None,
)
.await,
)
.await
.unwrap_err();
api_mock.assert_async().await;
assert!(matches!(error, RequestError::DeserializeError { .. }));
}
}

View file

@ -0,0 +1,25 @@
use cln_plugin::messages;
use serde::Deserialize;
pub mod http;
#[derive(Clone, Debug, Deserialize)]
pub struct ProxyInfo {
#[serde(flatten)]
/// The proxy data
inner: messages::ProxyInfo,
/// Whether to only send data though Tor or not
pub always_use: bool,
}
impl ProxyInfo {
pub fn new(proxy: messages::ProxyInfo, always_use: bool) -> Self {
Self {
inner: proxy,
always_use,
}
}
pub fn get_socks_addr(&self) -> String {
format!("socks5h://{}:{}", self.inner.address, self.inner.port)
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,70 @@
use bitcoin::consensus::encode;
use bitcoin::Transaction;
use teos_common::appointment::{Appointment, Locator};
use hex::FromHex;
use serde::{de, ser::SerializeMap, Deserializer, Serialize, Serializer};
use std::collections::HashMap;
pub fn deserialize_tx<'de, D>(deserializer: D) -> Result<Transaction, D::Error>
where
D: Deserializer<'de>,
{
struct TransactionVisitor;
impl<'de> de::Visitor<'de> for TransactionVisitor {
type Value = Transaction;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a hex string containing the transaction")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
let tx = encode::deserialize(
&Vec::from_hex(v).map_err(|_| E::custom("transaction is not hex encoded"))?,
)
.map_err(|_| E::custom("transaction cannot be deserialized"))?;
Ok(tx)
}
}
deserializer.deserialize_any(TransactionVisitor)
}
pub fn serialize_receipts<S>(hm: &HashMap<Locator, String>, s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut map = s.serialize_map(Some(hm.len()))?;
for (locator, sig) in hm {
map.serialize_entry(&hex::encode(locator), sig)?;
}
map.end()
}
#[derive(Serialize)]
struct AppointmentInners {
encrypted_blob: String,
to_self_delay: u32,
}
pub fn serialize_appointments<S>(v: &Vec<Appointment>, s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut map = s.serialize_map(Some(v.len()))?;
for a in v {
map.serialize_entry(
&hex::encode(a.locator),
&AppointmentInners {
encrypted_blob: hex::encode(&a.encrypted_blob),
to_self_delay: a.to_self_delay,
},
)?;
}
map.end()
}

View file

@ -0,0 +1,16 @@
use teos_common::appointment::Locator;
use teos_common::protos as common_msgs;
use teos_common::receipts::AppointmentReceipt;
pub fn get_dummy_add_appointment_response(
locator: Locator,
receipt: &AppointmentReceipt,
) -> common_msgs::AddAppointmentResponse {
common_msgs::AddAppointmentResponse {
locator: locator.to_vec(),
start_block: receipt.start_block(),
signature: receipt.signature().unwrap(),
available_slots: 21,
subscription_expiry: 1000,
}
}

View file

@ -0,0 +1,901 @@
use std::collections::{HashMap, HashSet};
use std::iter::FromIterator;
use std::path::PathBuf;
use tokio::fs;
use tokio::sync::mpsc::UnboundedSender;
use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey};
use teos_common::appointment::{Appointment, Locator};
use teos_common::cryptography;
use teos_common::dbm::Error as DBError;
use teos_common::receipts::{AppointmentReceipt, RegistrationReceipt};
use teos_common::{TowerId, UserId};
use crate::dbm::DBM;
use crate::net::ProxyInfo;
use crate::retrier::RetrierStatus;
use crate::{MisbehaviorProof, SubscriptionError, TowerInfo, TowerStatus, TowerSummary};
#[derive(Eq, PartialEq)]
pub enum RevocationData {
Fresh(Locator),
Stale(HashSet<Locator>),
None,
}
impl RevocationData {
pub fn is_none(&self) -> bool {
*self == RevocationData::None
}
}
impl From<RevocationData> for HashSet<Locator> {
fn from(r: RevocationData) -> Self {
match r {
RevocationData::Fresh(l) => HashSet::from_iter(vec![l]),
RevocationData::Stale(hs) => hs,
RevocationData::None => HashSet::new(),
}
}
}
impl std::fmt::Debug for RevocationData {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(
f,
"{}",
match self {
RevocationData::Fresh(l) => format!("Fresh: {l}"),
RevocationData::Stale(hs) => format!(
"Stale: {:?}",
hs.iter().map(|l| l.to_string()).collect::<Vec<_>>()
),
RevocationData::None => "None".to_owned(),
}
)
}
}
/// Represents the watchtower client that is being used as the CoreLN plugin state.
pub struct WTClient {
/// A [DBM] instance.
pub dbm: DBM,
/// A collection of towers the client is registered to.
pub towers: HashMap<TowerId, TowerSummary>,
/// Queue of unreachable towers.
pub unreachable_towers: UnboundedSender<(TowerId, RevocationData)>,
// Map of existing retriers and its state.
pub retriers: HashMap<TowerId, RetrierStatus>,
/// The user secret key.
pub user_sk: SecretKey,
/// The user identifier.
pub user_id: UserId,
/// Optional proxy
pub proxy: Option<ProxyInfo>,
}
impl WTClient {
pub async fn new(
data_dir: PathBuf,
unreachable_towers: UnboundedSender<(TowerId, RevocationData)>,
) -> Self {
Self::with_proxy(data_dir, unreachable_towers, None).await
}
pub async fn with_proxy(
data_dir: PathBuf,
unreachable_towers: UnboundedSender<(TowerId, RevocationData)>,
proxy: Option<ProxyInfo>,
) -> Self {
// Create data dir if it does not exist
fs::create_dir_all(&data_dir).await.unwrap_or_else(|e| {
log::error!("Cannot create data dir: {e:?}");
std::process::exit(1);
});
let dbm = DBM::new(&data_dir.join("watchtowers_db.sql3")).unwrap();
let (user_sk, user_id) = if let Some(sk) = dbm.load_client_key() {
(
sk,
UserId(PublicKey::from_secret_key(&Secp256k1::new(), &sk)),
)
} else {
log::info!("Watchtower client keys not found. Creating a fresh set");
let (sk, pk) = cryptography::get_random_keypair();
dbm.store_client_key(&sk).unwrap();
(sk, UserId(pk))
};
let towers = dbm.load_towers();
for (tower_id, tower) in towers.iter() {
if tower.status.is_temporary_unreachable() {
unreachable_towers
.send((
*tower_id,
RevocationData::Stale(tower.pending_appointments.iter().cloned().collect()),
))
.unwrap();
}
}
log::info!("Plugin watchtower client initialized. User id = {user_id}");
WTClient {
towers,
unreachable_towers,
retriers: HashMap::new(),
dbm,
user_sk,
user_id,
proxy,
}
}
/// Adds or updates a tower entry.
pub fn add_update_tower(
&mut self,
tower_id: TowerId,
tower_net_addr: &str,
receipt: &RegistrationReceipt,
) -> Result<(), SubscriptionError> {
if let Some(tower) = self.towers.get(&tower_id) {
// TODO: For now we're forcing updates to increase both slots and expiry. This is not mandatory and may
// be changed in the future, but the tower is currently set to do this anyway so let's keep it simple.
if receipt.subscription_expiry() <= tower.subscription_expiry {
return Err(SubscriptionError::Expiry);
} else {
let tower_info = self.dbm.load_tower_record(tower_id).unwrap();
if receipt.available_slots() <= tower_info.available_slots {
return Err(SubscriptionError::Slots);
}
}
}
self.dbm
.store_tower_record(tower_id, tower_net_addr, receipt)
.unwrap();
if let Some(summary) = self.towers.get_mut(&tower_id) {
summary.udpate(
tower_net_addr.to_owned(),
receipt.available_slots(),
receipt.subscription_start(),
receipt.subscription_expiry(),
);
} else {
self.towers.insert(
tower_id,
TowerSummary::new(
tower_net_addr.to_owned(),
receipt.available_slots(),
receipt.subscription_start(),
receipt.subscription_expiry(),
),
);
};
Ok(())
}
/// Gets the latest registration receipt of a given tower.
pub fn get_registration_receipt(&self, tower_id: TowerId) -> Option<RegistrationReceipt> {
self.dbm.load_registration_receipt(tower_id, self.user_id)
}
/// Loads a tower record from the database.
pub fn load_tower_info(&self, tower_id: TowerId) -> Option<TowerInfo> {
self.dbm.load_tower_record(tower_id)
}
/// Gets the given tower status (identified by tower_id), if found.
pub fn get_tower_status(&self, tower_id: &TowerId) -> Option<TowerStatus> {
Some(self.towers.get(tower_id)?.status)
}
/// Sets the tower status to any of the `TowerStatus` variants.
pub fn set_tower_status(&mut self, tower_id: TowerId, status: TowerStatus) {
if let Some(tower) = self.towers.get_mut(&tower_id) {
if tower.status != status {
tower.status = status
} else {
log::debug!("{tower_id} status is already {status}")
}
} else {
log::error!("Cannot change tower status to {status}. Unknown tower_id: {tower_id}");
}
}
/// Gets the given tower status (identified by tower_id), if found.
pub fn get_retrier_status(&self, tower_id: &TowerId) -> Option<&RetrierStatus> {
self.retriers.get(tower_id)
}
/// Adds an appointment receipt to the tower record.
pub fn add_appointment_receipt(
&mut self,
tower_id: TowerId,
locator: Locator,
available_slots: u32,
receipt: &AppointmentReceipt,
) {
if let Some(tower) = self.towers.get_mut(&tower_id) {
// DISCUSS: It may be nice to independently compute the slots and compare
tower.available_slots = available_slots;
self.dbm
.store_appointment_receipt(tower_id, locator, available_slots, receipt)
.unwrap();
} else {
log::error!("Cannot add appointment receipt to tower. Unknown tower_id: {tower_id}");
}
}
/// Gets an appointment receipt from the database (if found).
pub fn get_appointment_receipt(
&self,
tower_id: TowerId,
locator: Locator,
) -> Option<AppointmentReceipt> {
self.dbm.load_appointment_receipt(tower_id, locator)
}
/// Adds a pending appointment to the tower record.
pub fn add_pending_appointment(&mut self, tower_id: TowerId, appointment: &Appointment) {
if let Some(tower) = self.towers.get_mut(&tower_id) {
tower.pending_appointments.insert(appointment.locator);
self.dbm
.store_pending_appointment(tower_id, appointment)
.unwrap();
} else {
log::error!("Cannot add pending appointment to tower. Unknown tower_id: {tower_id}");
}
}
/// Removes a pending appointment from the tower record.
pub fn remove_pending_appointment(&mut self, tower_id: TowerId, locator: Locator) {
if let Some(tower) = self.towers.get_mut(&tower_id) {
tower.pending_appointments.remove(&locator);
self.dbm
.delete_pending_appointment(tower_id, locator)
.unwrap();
} else {
log::error!("Cannot remove pending appointment to tower. Unknown tower_id: {tower_id}");
}
}
/// Adds an invalid appointment to the tower record.
pub fn add_invalid_appointment(&mut self, tower_id: TowerId, appointment: &Appointment) {
if let Some(tower) = self.towers.get_mut(&tower_id) {
tower.invalid_appointments.insert(appointment.locator);
self.dbm
.store_invalid_appointment(tower_id, appointment)
.unwrap();
} else {
log::error!("Cannot add invalid appointment to tower. Unknown tower_id: {tower_id}");
}
}
/// Flags a given tower as misbehaving, storing the misbehaving proof in the database.
pub fn flag_misbehaving_tower(&mut self, tower_id: TowerId, proof: MisbehaviorProof) {
if let Some(tower) = self.towers.get_mut(&tower_id) {
self.dbm.store_misbehaving_proof(tower_id, &proof).unwrap();
tower.status = TowerStatus::Misbehaving;
} else {
log::error!("Cannot flag tower. Unknown tower_id: {tower_id}");
}
}
/// Removes a tower from the client (both memory and database).
///
/// Any data associated to the tower will be deleted (i.e. links to appointments)
pub fn remove_tower(&mut self, tower_id: TowerId) -> Result<(), DBError> {
if self.towers.contains_key(&tower_id) {
self.towers.remove(&tower_id);
self.dbm.remove_tower_record(tower_id)
} else {
Err(DBError::NotFound)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempdir::TempDir;
use tokio::sync::mpsc::unbounded_channel;
use teos_common::test_utils::{
generate_random_appointment, get_random_appointment_receipt,
get_random_registration_receipt, get_random_user_id,
get_registration_receipt_from_previous,
};
#[tokio::test]
async fn test_add_update_load_tower() {
let tmp_path = TempDir::new(&format!("watchtower_{}", get_random_user_id())).unwrap();
let mut wt_client =
WTClient::new(tmp_path.path().to_path_buf(), unbounded_channel().0).await;
// Adding a new tower will add a summary to towers and the full data to the
let mut receipt = get_random_registration_receipt();
let (tower_sk, tower_pk) = cryptography::get_random_keypair();
let tower_id = TowerId(tower_pk);
let tower_info = TowerInfo::empty(
"talaia.watch".to_owned(),
receipt.available_slots(),
receipt.subscription_start(),
receipt.subscription_expiry(),
);
wt_client
.add_update_tower(tower_id, &tower_info.net_addr, &receipt)
.unwrap();
assert_eq!(
wt_client.towers.get(&tower_id),
Some(&TowerSummary::from(tower_info.clone()))
);
assert_eq!(wt_client.load_tower_info(tower_id).unwrap(), tower_info);
// Calling the method again with updated information should also updated the records in memory and the database
receipt = get_registration_receipt_from_previous(&receipt);
let updated_tower_info = TowerInfo::empty(
"talaia.watch".to_owned(),
receipt.available_slots(),
receipt.subscription_start(),
receipt.subscription_expiry(),
);
wt_client
.add_update_tower(tower_id, &updated_tower_info.net_addr, &receipt)
.unwrap();
assert_eq!(
wt_client.towers.get(&tower_id),
Some(&TowerSummary::from(updated_tower_info.clone()))
);
assert_eq!(
wt_client.load_tower_info(tower_id).unwrap(),
updated_tower_info
);
// If we try to update without increasing both the end_time and the slots, this will fail
let mut receipt_same_slots = RegistrationReceipt::new(
receipt.user_id(),
receipt.available_slots(),
receipt.subscription_start(),
receipt.subscription_expiry() + 1,
);
receipt_same_slots.sign(&tower_sk);
let mut receipt_same_expiry = RegistrationReceipt::new(
receipt.user_id(),
receipt.available_slots() + 1,
receipt.subscription_start(),
receipt.subscription_expiry(),
);
receipt_same_expiry.sign(&tower_sk);
assert!(matches!(
wt_client.add_update_tower(tower_id, &updated_tower_info.net_addr, &receipt),
Err(SubscriptionError::Expiry)
));
assert!(matches!(
wt_client.add_update_tower(tower_id, &updated_tower_info.net_addr, &receipt_same_slots),
Err(SubscriptionError::Slots)
));
assert!(matches!(
wt_client.add_update_tower(
tower_id,
&updated_tower_info.net_addr,
&receipt_same_expiry
),
Err(SubscriptionError::Expiry)
));
// Decrease the slots count (simulate exhaustion) and update with more than the current count it should work
let locator = generate_random_appointment(None).locator;
wt_client.add_appointment_receipt(
tower_id,
locator,
0,
&get_random_appointment_receipt(tower_sk),
);
wt_client
.add_update_tower(tower_id, &updated_tower_info.net_addr, &receipt_same_slots)
.unwrap();
}
#[tokio::test]
async fn test_get_tower_status() {
let tmp_path = TempDir::new(&format!("watchtower_{}", get_random_user_id())).unwrap();
let mut wt_client =
WTClient::new(tmp_path.path().to_path_buf(), unbounded_channel().0).await;
// If the tower is unknown, get_tower_status returns None
let tower_id = get_random_user_id();
assert!(wt_client.get_tower_status(&tower_id).is_none());
// Add a tower
let receipt = get_random_registration_receipt();
wt_client
.add_update_tower(tower_id, "talaia.watch", &receipt)
.unwrap();
// If the tower is known, get_tower_status matches getting the same data from the towers collection
assert_eq!(
wt_client.towers.get(&tower_id).unwrap().status,
wt_client.get_tower_status(&tower_id).unwrap()
)
}
#[tokio::test]
async fn test_set_tower_status() {
let tmp_path = TempDir::new(&format!("watchtower_{}", get_random_user_id())).unwrap();
let mut wt_client =
WTClient::new(tmp_path.path().to_path_buf(), unbounded_channel().0).await;
// If the tower is unknown nothing will happen
let unknown_tower = get_random_user_id();
wt_client.set_tower_status(unknown_tower, TowerStatus::Reachable);
assert!(!wt_client.towers.contains_key(&unknown_tower));
// If the tower is known, the status will be updated.
let receipt = get_random_registration_receipt();
let tower_id = get_random_user_id();
wt_client
.add_update_tower(tower_id, "talaia.watch", &receipt)
.unwrap();
for status in [
TowerStatus::Reachable,
TowerStatus::TemporaryUnreachable,
TowerStatus::Unreachable,
TowerStatus::SubscriptionError,
TowerStatus::Misbehaving,
] {
wt_client.set_tower_status(tower_id, status);
assert_eq!(status, wt_client.get_tower_status(&tower_id).unwrap());
}
}
#[tokio::test]
async fn test_add_appointment_receipt() {
let tmp_path = TempDir::new(&format!("watchtower_{}", get_random_user_id())).unwrap();
let mut wt_client =
WTClient::new(tmp_path.path().to_path_buf(), unbounded_channel().0).await;
let (tower_sk, tower_pk) = cryptography::get_random_keypair();
let tower_id = TowerId(tower_pk);
let locator = generate_random_appointment(None).locator;
let registration_receipt = get_random_registration_receipt();
let appointment_receipt = get_random_appointment_receipt(tower_sk);
// If we call this on an unknown tower it will simply do nothing
wt_client.add_appointment_receipt(
tower_id,
locator,
registration_receipt.available_slots(),
&appointment_receipt,
);
assert!(!wt_client.towers.contains_key(&tower_id));
// Add the tower to the state and try again
let tower_info = TowerInfo::new(
"talaia.watch".to_owned(),
registration_receipt.available_slots(),
registration_receipt.subscription_start(),
registration_receipt.subscription_expiry(),
HashMap::from([(locator, appointment_receipt.signature().unwrap())]),
Vec::new(),
Vec::new(),
);
wt_client
.add_update_tower(tower_id, &tower_info.net_addr, &registration_receipt)
.unwrap();
wt_client.add_appointment_receipt(
tower_id,
locator,
registration_receipt.available_slots(),
&appointment_receipt,
);
assert!(wt_client.towers.contains_key(&tower_id));
assert_eq!(
wt_client.towers.get(&tower_id).unwrap(),
&TowerSummary::from(tower_info.clone())
);
assert_eq!(wt_client.load_tower_info(tower_id).unwrap(), tower_info);
}
#[tokio::test]
async fn test_add_pending_appointment() {
let tmp_path = TempDir::new(&format!("watchtower_{}", get_random_user_id())).unwrap();
let mut wt_client =
WTClient::new(tmp_path.path().to_path_buf(), unbounded_channel().0).await;
let tower_id = get_random_user_id();
let registration_receipt = get_random_registration_receipt();
let appointment = generate_random_appointment(None);
// If we call this on an unknown tower it will simply do nothing
wt_client.add_pending_appointment(tower_id, &appointment);
assert!(!wt_client.towers.contains_key(&tower_id));
// Add the tower to the state and try again
let tower_info = TowerInfo::new(
"talaia.watch".to_owned(),
registration_receipt.available_slots(),
registration_receipt.subscription_start(),
registration_receipt.subscription_expiry(),
HashMap::new(),
vec![appointment.clone()],
Vec::new(),
);
wt_client
.add_update_tower(tower_id, &tower_info.net_addr, &registration_receipt)
.unwrap();
wt_client.add_pending_appointment(tower_id, &appointment);
assert!(wt_client.towers.contains_key(&tower_id));
assert_eq!(
wt_client.towers.get(&tower_id).unwrap(),
&TowerSummary::from(tower_info.clone())
);
// When towers data is loaded from the database, it is assumed to be reachable.
assert_eq!(
wt_client.load_tower_info(tower_id).unwrap(),
tower_info.with_status(TowerStatus::TemporaryUnreachable)
);
}
#[tokio::test]
async fn test_remove_pending_appointment() {
let tmp_path = TempDir::new(&format!("watchtower_{}", get_random_user_id())).unwrap();
let mut wt_client =
WTClient::new(tmp_path.path().to_path_buf(), unbounded_channel().0).await;
let tower_id = get_random_user_id();
let registration_receipt = get_random_registration_receipt();
let appointment = generate_random_appointment(None);
// If we call this on an unknown tower it will simply do nothing
wt_client.remove_pending_appointment(tower_id, appointment.locator);
// Add the tower to the state and try again
wt_client
.add_update_tower(tower_id, "talaia.watch", &registration_receipt)
.unwrap();
wt_client.add_pending_appointment(tower_id, &appointment);
wt_client.remove_pending_appointment(tower_id, appointment.locator);
assert!(!wt_client
.towers
.get(&tower_id)
.unwrap()
.pending_appointments
.contains(&appointment.locator));
// This bit is tested exhaustively in the DBM.
assert!(!wt_client.dbm.appointment_exists(appointment.locator));
}
#[tokio::test]
async fn test_add_invalid_appointment() {
let tmp_path = TempDir::new(&format!("watchtower_{}", get_random_user_id())).unwrap();
let mut wt_client =
WTClient::new(tmp_path.path().to_path_buf(), unbounded_channel().0).await;
let tower_id = get_random_user_id();
let registration_receipt = get_random_registration_receipt();
let appointment = generate_random_appointment(None);
// If we call this on an unknown tower it will simply do nothing
wt_client.add_invalid_appointment(tower_id, &appointment);
assert!(!wt_client.towers.contains_key(&tower_id));
// Add the tower to the state and try again
let tower_info = TowerInfo::new(
"talaia.watch".to_owned(),
registration_receipt.available_slots(),
registration_receipt.subscription_start(),
registration_receipt.subscription_expiry(),
HashMap::new(),
Vec::new(),
vec![appointment.clone()],
);
wt_client
.add_update_tower(tower_id, &tower_info.net_addr, &registration_receipt)
.unwrap();
wt_client.add_invalid_appointment(tower_id, &appointment);
assert!(wt_client.towers.contains_key(&tower_id));
assert_eq!(
wt_client.towers.get(&tower_id).unwrap(),
&TowerSummary::from(tower_info.clone())
);
assert_eq!(wt_client.load_tower_info(tower_id).unwrap(), tower_info);
}
#[tokio::test]
async fn test_move_pending_appointment_to_invalid() {
let tmp_path = TempDir::new(&format!("watchtower_{}", get_random_user_id())).unwrap();
let mut wt_client =
WTClient::new(tmp_path.path().to_path_buf(), unbounded_channel().0).await;
let tower_id = get_random_user_id();
let registration_receipt = get_random_registration_receipt();
let appointment = generate_random_appointment(None);
wt_client
.add_update_tower(tower_id, "talaia.watch", &registration_receipt)
.unwrap();
wt_client.add_pending_appointment(tower_id, &appointment);
// Check that the appointment can be moved from pending to invalid
wt_client.add_invalid_appointment(tower_id, &appointment);
wt_client.remove_pending_appointment(tower_id, appointment.locator);
assert!(!wt_client
.towers
.get(&tower_id)
.unwrap()
.pending_appointments
.contains(&appointment.locator));
assert!(wt_client
.towers
.get(&tower_id)
.unwrap()
.invalid_appointments
.contains(&appointment.locator));
assert!(!wt_client
.dbm
.load_appointment_locators(tower_id, crate::AppointmentStatus::Pending)
.contains(&appointment.locator));
assert!(wt_client
.dbm
.load_appointment_locators(tower_id, crate::AppointmentStatus::Invalid)
.contains(&appointment.locator));
assert!(wt_client.dbm.appointment_exists(appointment.locator));
}
#[tokio::test]
async fn test_move_pending_appointment_to_invalid_multiple_towers() {
// Check that moving an appointment from pending to invalid can be done even if multiple towers have a reference to it
let tmp_path = TempDir::new(&format!("watchtower_{}", get_random_user_id())).unwrap();
let mut wt_client =
WTClient::new(tmp_path.path().to_path_buf(), unbounded_channel().0).await;
let tower_id = get_random_user_id();
let another_tower_id = get_random_user_id();
let tower_net_addr = "talaia.watch";
let registration_receipt = get_random_registration_receipt();
let appointment = generate_random_appointment(None);
wt_client
.add_update_tower(tower_id, tower_net_addr, &registration_receipt)
.unwrap();
wt_client
.add_update_tower(another_tower_id, tower_net_addr, &registration_receipt)
.unwrap();
wt_client.add_pending_appointment(tower_id, &appointment);
wt_client.add_pending_appointment(another_tower_id, &appointment);
// Check that the appointment can be moved from pending to invalid
wt_client.add_invalid_appointment(tower_id, &appointment);
wt_client.remove_pending_appointment(tower_id, appointment.locator);
// TOWER_ID CHECKS
assert!(!wt_client
.towers
.get(&tower_id)
.unwrap()
.pending_appointments
.contains(&appointment.locator));
assert!(wt_client
.towers
.get(&tower_id)
.unwrap()
.invalid_appointments
.contains(&appointment.locator));
assert!(!wt_client
.dbm
.load_appointment_locators(tower_id, crate::AppointmentStatus::Pending)
.contains(&appointment.locator));
assert!(wt_client
.dbm
.load_appointment_locators(tower_id, crate::AppointmentStatus::Invalid)
.contains(&appointment.locator));
// ANOTHER_TOWER_ID CHECKS
assert!(wt_client
.towers
.get(&another_tower_id)
.unwrap()
.pending_appointments
.contains(&appointment.locator));
assert!(!wt_client
.towers
.get(&another_tower_id)
.unwrap()
.invalid_appointments
.contains(&appointment.locator));
assert!(wt_client
.dbm
.load_appointment_locators(another_tower_id, crate::AppointmentStatus::Pending)
.contains(&appointment.locator));
assert!(!wt_client
.dbm
.load_appointment_locators(another_tower_id, crate::AppointmentStatus::Invalid)
.contains(&appointment.locator));
// GENERAL
assert!(wt_client.dbm.appointment_exists(appointment.locator));
}
#[tokio::test]
async fn test_flag_misbehaving_tower() {
let tmp_path = TempDir::new(&format!("watchtower_{}", get_random_user_id())).unwrap();
let mut wt_client =
WTClient::new(tmp_path.path().to_path_buf(), unbounded_channel().0).await;
let (tower_sk, tower_pk) = cryptography::get_random_keypair();
let tower_id = TowerId(tower_pk);
// If we call this on an unknown tower it will simply do nothing
let appointment = generate_random_appointment(None);
let receipt = get_random_appointment_receipt(tower_sk);
let proof = MisbehaviorProof::new(appointment.locator, receipt, get_random_user_id());
wt_client.flag_misbehaving_tower(tower_id, proof.clone());
assert!(!wt_client.towers.contains_key(&tower_id));
// // Add the tower to the state and try again
let registration_receipt = get_random_registration_receipt();
wt_client
.add_update_tower(tower_id, "talaia.watch", &registration_receipt)
.unwrap();
wt_client.flag_misbehaving_tower(tower_id, proof.clone());
// Check data in memory
let tower_summary = wt_client.towers.get(&tower_id);
assert!(tower_summary.is_some());
assert!(tower_summary.unwrap().status.is_misbehaving());
// Check data in DB
let loaded_info = wt_client.load_tower_info(tower_id).unwrap();
assert!(loaded_info.status.is_misbehaving());
assert_eq!(loaded_info.misbehaving_proof, Some(proof));
assert!(loaded_info.appointments.contains_key(&appointment.locator));
}
#[tokio::test]
async fn test_remove_tower() {
let tmp_path = TempDir::new(&format!("watchtower_{}", get_random_user_id())).unwrap();
let mut wt_client =
WTClient::new(tmp_path.path().to_path_buf(), unbounded_channel().0).await;
let receipt = get_random_registration_receipt();
let (tower_sk, tower_pk) = cryptography::get_random_keypair();
let tower_id = TowerId(tower_pk);
let tower_info = TowerInfo::empty(
"talaia.watch".to_owned(),
receipt.available_slots(),
receipt.subscription_start(),
receipt.subscription_expiry(),
);
// Add the tower and check it is there
wt_client
.add_update_tower(tower_id, &tower_info.net_addr, &receipt)
.unwrap();
assert_eq!(
wt_client.towers.get(&tower_id),
Some(&TowerSummary::from(tower_info.clone()))
);
assert_eq!(wt_client.load_tower_info(tower_id).unwrap(), tower_info);
// Remove the tower and check it is not there anymore
wt_client.remove_tower(tower_id).unwrap();
assert!(wt_client.load_tower_info(tower_id).is_none());
assert!(!wt_client.towers.contains_key(&tower_id));
// Try again but this time with an associated appointment to check that it also gets removed
wt_client
.add_update_tower(tower_id, &tower_info.net_addr, &receipt)
.unwrap();
let locator = generate_random_appointment(None).locator;
let registration_receipt = get_random_registration_receipt();
let appointment_receipt = get_random_appointment_receipt(tower_sk);
// If we call this on an unknown tower it will simply do nothing
wt_client.add_appointment_receipt(
tower_id,
locator,
registration_receipt.available_slots(),
&appointment_receipt,
);
assert!(wt_client.dbm.appointment_receipt_exists(locator, tower_id));
// Remove and check both the tower and the appointment
wt_client.remove_tower(tower_id).unwrap();
assert!(wt_client.load_tower_info(tower_id).is_none());
assert!(!wt_client.towers.contains_key(&tower_id));
assert!(!wt_client.dbm.appointment_receipt_exists(locator, tower_id));
}
#[tokio::test]
async fn test_remove_tower_shared_appointment() {
// Lets test removing a tower that has associated data shared with another tower.
// For instance, having an appointment that was sent to two towers, and then deleting one of them
// should only remove the link between the tower and the appointment, but not delete the data.
let tmp_path = TempDir::new(&format!("watchtower_{}", get_random_user_id())).unwrap();
let mut wt_client =
WTClient::new(tmp_path.path().to_path_buf(), unbounded_channel().0).await;
let receipt = get_random_registration_receipt();
let (tower1_sk, tower1_pk) = cryptography::get_random_keypair();
let tower1_id = TowerId(tower1_pk);
let (tower2_sk, tower2_pk) = cryptography::get_random_keypair();
let tower2_id = TowerId(tower2_pk);
wt_client
.add_update_tower(tower1_id, "talaia.watch", &receipt)
.unwrap();
wt_client
.add_update_tower(tower2_id, "talaia.watch", &receipt)
.unwrap();
let locator = generate_random_appointment(None).locator;
let registration_receipt = get_random_registration_receipt();
let appointment_receipt_1 = get_random_appointment_receipt(tower1_sk);
let appointment_receipt_2 = get_random_appointment_receipt(tower2_sk);
wt_client.add_appointment_receipt(
tower1_id,
locator,
registration_receipt.available_slots(),
&appointment_receipt_1,
);
wt_client.add_appointment_receipt(
tower2_id,
locator,
registration_receipt.available_slots(),
&appointment_receipt_2,
);
// Check that the data exists in both towers
assert!(wt_client.dbm.appointment_receipt_exists(locator, tower1_id));
assert!(wt_client.dbm.appointment_receipt_exists(locator, tower2_id));
// Remove tower1 and check that the appointment receipt can still be found for tower2
wt_client.remove_tower(tower1_id).unwrap();
assert!(wt_client.load_tower_info(tower1_id).is_none());
assert!(!wt_client.dbm.appointment_receipt_exists(locator, tower1_id));
assert!(wt_client.dbm.appointment_receipt_exists(locator, tower2_id));
}
#[tokio::test]
async fn test_remove_inexistent_tower() {
let tmp_path = TempDir::new(&format!("watchtower_{}", get_random_user_id())).unwrap();
let mut wt_client =
WTClient::new(tmp_path.path().to_path_buf(), unbounded_channel().0).await;
assert!(matches!(
wt_client.remove_tower(get_random_user_id()),
Err(DBError::NotFound)
));
}
}

View file

@ -0,0 +1,122 @@
from pathlib import Path
import subprocess
from pyln.testing.fixtures import * # noqa: F401,F403
from pyln.testing.utils import BITCOIND_CONFIG, TailableProc
WT_PLUGIN = Path("~/.cargo/bin/watchtower-client").expanduser()
TEOSD_CONFIG = {
"btc_network": "regtest",
"polling_delta": 0,
}
def write_toml_config(filename, opts):
with open(filename, "w") as f:
for k, v in opts.items():
if isinstance(v, str):
f.write('{} = "{}"\n'.format(k, v))
else:
f.write("{} = {}\n".format(k, v))
class TeosCLI:
def __init__(self, directory="/tmp/watchtower-test"):
self.datadir = directory
def _call(self, method_name, *args):
try:
r = subprocess.run(
["teos-cli", f"--datadir={self.datadir}/teos", method_name, *args],
capture_output=True,
text=True,
)
if r.returncode != 0:
result = ValueError(f"Unknown method {method_name}")
else:
result = json.loads(r.stdout)
except json.JSONDecodeError:
result = None
return result
def __getattr__(self, name):
if name.startswith("__") and name.endswith("__"):
# Prevent RPC calls for non-existing python internal attribute
# access. If someone tries to get an internal attribute
# of RawProxy instance, and the instance does not have this
# attribute, we do not want the bogus RPC call to happen.
raise AttributeError
# Create a callable to do the actual call
f = lambda *args: self._call(name, *args) # noqa: E731
# Make debuggers show <function teos.cli.name> rather than <function
# teos.cli.<lambda>>
f.__name__ = name
return f
class TeosD(TailableProc):
def __init__(self, bitcoind_rpcport, directory="/tmp/watchtower-test"):
self.teos_dir = os.path.join(directory, "teos")
self.prefix = "teosd"
TailableProc.__init__(self, self.teos_dir)
self.cli = TeosCLI(directory)
if not os.path.exists(self.teos_dir):
os.makedirs(self.teos_dir)
self.cmd_line = [
"teosd",
f"--datadir={self.teos_dir}",
f'--btcrpcuser={BITCOIND_CONFIG["rpcuser"]}',
f'--btcrpcpassword={BITCOIND_CONFIG["rpcpassword"]}',
f"--btcrpcport={bitcoind_rpcport}",
]
self.conf_file = os.path.join(self.teos_dir, "teos.toml")
write_toml_config(self.conf_file, TEOSD_CONFIG)
def start(self, overwrite_key=False):
if overwrite_key:
self.cmd_line.append("--overwritekey")
TailableProc.start(self)
self.wait_for_log("Tower ready")
logging.info("TeosD started")
def stop(self):
self.cli.stop()
self.wait_for_log("Shutting down tower")
return TailableProc.stop(self)
@pytest.fixture
def teosd(bitcoind, directory):
# Set the user data dir for the watchtower-plugin so it uses a unique one per test.
os.environ["TOWERS_DATA_DIR"] = os.path.join(directory, "watchtower")
teosd = TeosD(directory=directory, bitcoind_rpcport=bitcoind.rpcport)
teosd.start()
yield teosd
teosd.stop()
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
# execute all other hooks to obtain the report object
outcome = yield
rep = outcome.get_result()
# set a report attribute for each phase of a call, which can
# be "setup", "call", "teardown"
setattr(item, "rep_" + rep.when, rep)
@pytest.fixture(scope="function", autouse=True)
def log_name(request):
# Here logging is used, you can use whatever you want to use for logs
logging.info("Starting '{}'".format(request.node.name))

View file

@ -0,0 +1,21 @@
[tool.poetry]
name = "tests"
version = "0.1.2"
description = "watchtower-plugin tests"
authors = ["Sergi Delgado Segura <sergi.delgado.s@gmail.com>"]
license = "MIT"
[tool.poetry.dependencies]
python = "^3.9"
black = "^22.6.0"
[tool.poetry.dev-dependencies]
pytest = "^7.1.2"
pytest-timeout = "^2.1.0"
pyln-testing = "^24.2.1"
pyln-client = "^24.2.1"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"

View file

@ -0,0 +1,243 @@
import pytest
from conftest import WT_PLUGIN
def change_endianness(x):
"""Changes the endianness (from BE to LE and vice versa) of a given value.
:param x: Given value which endianness will be changed.
:type x: hex str
:return: The opposite endianness representation of the given value.
:rtype: hex str
"""
b = bytes.fromhex(x)
return b[::-1].hex()
def test_watchtower(node_factory, bitcoind, teosd):
"""
Test watchtower hook.
l1 and l2 open a channel, make a couple of updates and then l1 cheats on
l2 while that one is offline. The watchtower plugin meanwhile stashes all
the penalty transactions and we release the one matching the offending
commitment transaction.
"""
l1, l2 = node_factory.line_graph(
2,
opts=[
{"broken_log": r"Could not find resolution for output [0-9]?: did \*we\* cheat\?"},
{"plugin": WT_PLUGIN},
],
)
# We need to register l2 with the tower
tower_id = teosd.cli.gettowerinfo()["tower_id"]
l2.rpc.registertower(tower_id)
# Force a new commitment
l1.rpc.pay(l2.rpc.invoice(25000000, "lbl1", "desc1")["bolt11"])
tx = l1.rpc.dev_sign_last_tx(l2.info["id"])["tx"]
# Now make sure it is out of date
l1.rpc.pay(l2.rpc.invoice(25000000, "lbl2", "desc2")["bolt11"])
# l2 stops watching the chain, allowing the watchtower to react
l2.stop()
# Now l1 cheats
dispute_txid = bitcoind.rpc.sendrawtransaction(tx)
locator = change_endianness(dispute_txid[32:])
# Make sure l2's normal penalty_tx doesn't reach the network
l2.daemon.rpcproxy.mock_rpc("sendrawtransaction", lambda _: {"result": None, "error": None, "id": "pytest"})
l2.start()
# The tower will react once the dispute gets confirmed. For now it is still watching for it
assert l2.rpc.getappointment(tower_id, locator)["status"] == "being_watched"
# Confirm the dispute so the tower can react with the penalty
bitcoind.generate_block()
l1.daemon.wait_for_log("State changed from FUNDING_SPEND_SEEN to ONCHAIN")
penalty_txid = bitcoind.rpc.getrawmempool()[0]
# The channel still exists between the two peers, but it's on chain
assert l1.rpc.listpeerchannels()["channels"][0]["state"] == "ONCHAIN"
assert l2.rpc.getappointment(tower_id, locator)["status"] == "dispute_responded"
# Generate blocks until the penalty gets irrevocably resolved
for i in range(101):
bitcoind.generate_block()
if i < 100:
assert l2.rpc.getappointment(tower_id, locator)["status"] == "dispute_responded"
else:
# Once the channel gets irrevocably resolved the tower will forget about it
assert l2.rpc.getappointment(tower_id, locator) == {
"error": "Appointment not found",
"error_code": 36,
}
# Make sure the penalty outputs are in l2's wallet
fund_txids = [o["txid"] for o in l2.rpc.listfunds()["outputs"]]
assert penalty_txid in fund_txids
@pytest.mark.timeout(60)
def test_unreachable_watchtower(node_factory, bitcoind, teosd):
# Set the max retry interval to 1 sec so we know how much to wait for the next retry attempt
max_interval_time = 1
l1, l2 = node_factory.line_graph(
2,
opts=[
{},
{
"plugin": WT_PLUGIN,
"dev-watchtower-max-retry-interval": max_interval_time,
},
],
)
# We need to register l2 with the tower
tower_id = teosd.cli.gettowerinfo()["tower_id"]
l2.rpc.registertower(tower_id)
# Stop the tower
teosd.stop()
# Make a new payment with an unreachable tower
l1.rpc.pay(l2.rpc.invoice(25000000, "lbl1", "desc1")["bolt11"])
assert l2.rpc.gettowerinfo(tower_id)["status"] == "temporary_unreachable"
assert l2.rpc.gettowerinfo(tower_id)["pending_appointments"]
# Start the tower and check the automatic backoff works
teosd.start()
l2.daemon.wait_for_log(f"Retry strategy succeeded for {tower_id}")
assert l2.rpc.gettowerinfo(tower_id)["status"] == "reachable"
def test_auto_retry_watchtower(node_factory, bitcoind, teosd):
# The plugin is set to give up on retrying straight-away so we can test this fast.
l1, l2 = node_factory.line_graph(
2,
opts=[
{},
{
"plugin": WT_PLUGIN,
"broken_log": r"plugin-watchtower-client: Data was send to an idle retrier. This should have never happened. Please report!.*",
"watchtower-max-retry-time": 1,
"watchtower-auto-retry-delay": 1,
},
],
)
# We need to register l2 with the tower
tower_id = teosd.cli.gettowerinfo()["tower_id"]
l2.rpc.registertower(tower_id)
# Stop the tower
teosd.stop()
# Make a new payment with an unreachable tower
l1.rpc.pay(l2.rpc.invoice(25000000, "lbl1", "desc1")["bolt11"])
# Wait until the tower has been flagged as unreachable
l2.daemon.wait_for_log("Starting to idle")
assert l2.rpc.gettowerinfo(tower_id)["status"] == "unreachable"
assert l2.rpc.gettowerinfo(tower_id)["pending_appointments"]
# Start the tower and retry it
teosd.start()
l2.daemon.wait_for_log(f"Finished idling. Flagging {tower_id} for retry")
l2.daemon.wait_for_log(f"Retry strategy succeeded for {tower_id}")
assert l2.rpc.gettowerinfo(tower_id)["status"] == "reachable"
def test_manually_retry_watchtower(node_factory, bitcoind, teosd):
# The plugin is set to give up on retrying straight-away so we can test this fast.
l1, l2 = node_factory.line_graph(
2,
opts=[
{},
{
"plugin": WT_PLUGIN,
"watchtower-max-retry-time": 0,
},
],
)
# We need to register l2 with the tower
tower_id = teosd.cli.gettowerinfo()["tower_id"]
l2.rpc.registertower(tower_id)
# Stop the tower
teosd.stop()
# Make a new payment with an unreachable tower
l1.rpc.pay(l2.rpc.invoice(25000000, "lbl1", "desc1")["bolt11"])
# Wait until the tower has been flagged as unreachable
l2.daemon.wait_for_log("Starting to idle")
assert l2.rpc.gettowerinfo(tower_id)["status"] == "unreachable"
assert l2.rpc.gettowerinfo(tower_id)["pending_appointments"]
# Start the tower and retry it
teosd.start()
# Manual retry
l2.rpc.retrytower(tower_id)
l2.daemon.wait_for_log(f"Manually finished idling. Flagging {tower_id} for retry")
l2.daemon.wait_for_log(f"Retry strategy succeeded for {tower_id}")
assert l2.rpc.gettowerinfo(tower_id)["status"] == "reachable"
def test_misbehaving_watchtower(node_factory, bitcoind, teosd, directory):
l1, l2 = node_factory.line_graph(2, opts=[{}, {"plugin": WT_PLUGIN}])
# We need to register l2 with the tower
tower_id = teosd.cli.gettowerinfo()["tower_id"]
l2.rpc.registertower(tower_id)
# Restart overwriting the tower private key
teosd.stop()
teosd.start(overwrite_key=True)
# Make a new payment and check the state
l1.rpc.pay(l2.rpc.invoice(25000000, "lbl1", "desc1")["bolt11"])
assert l2.rpc.gettowerinfo(tower_id)["status"] == "misbehaving"
assert l2.rpc.gettowerinfo(tower_id)["misbehaving_proof"]
def test_get_appointment(node_factory, bitcoind, teosd, directory):
l1, l2 = node_factory.line_graph(2, opts=[{}, {"plugin": WT_PLUGIN}])
# We need to register l2 with the tower
tower_id = teosd.cli.gettowerinfo()["tower_id"]
l2.rpc.registertower(tower_id)
# Force a new commitment
l1.rpc.pay(l2.rpc.invoice(25000000, "lbl1", "desc1")["bolt11"])
tx = l1.rpc.dev_sign_last_tx(l2.info["id"])["tx"]
# Now make sure it is out of date
l1.rpc.pay(l2.rpc.invoice(25000000, "lbl2", "desc2")["bolt11"])
# Now l1 cheats
dispute_txid = bitcoind.rpc.sendrawtransaction(tx)
locator = change_endianness(dispute_txid[32:])
# Check the appointment before mining a block
appointment = l2.rpc.getappointment(tower_id, locator)["appointment"]
assert "locator" in appointment and "encrypted_blob" in appointment and "to_self_delay" in appointment
# And after. Now this should be a tracker
bitcoind.generate_block()
teosd.wait_for_log("New tracker added")
tracker = l2.rpc.getappointment(tower_id, locator)["appointment"]
assert "dispute_txid" in tracker and "penalty_txid" in tracker and "penalty_rawtx" in tracker
# Manually stop l2, otherwise the tower may be stopped before the tower client and we may get some BROKEN logs.
l2.stop()