├── .github └── dependabot.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── docker-compose.yml ├── grpc_generated ├── __init__.py ├── invoices_pb2.py ├── invoices_pb2_grpc.py ├── lightning_pb2.py ├── lightning_pb2_grpc.py ├── router_pb2.py └── router_pb2_grpc.py ├── lnd.py ├── logic.py ├── output.py ├── rebalance.py ├── requirements.txt ├── routes.py ├── setup.py ├── test.Dockerfile └── tests ├── README.md └── unit ├── __init__.py └── test_output.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: protobuf 11 | versions: 12 | - 3.15.0 13 | - 3.15.1 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | *.iml 3 | venv/ 4 | grpc_generated/*.proto 5 | **/__pycache__ 6 | *.proto 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-alpine3.14 2 | 3 | RUN apk add --update --no-cache linux-headers gcc g++ git openssh-client \ 4 | && apk add libstdc++ --no-cache --repository http://dl-3.alpinelinux.org/alpine/edge/testing/ --allow-untrusted \ 5 | && git clone --depth=1 https://github.com/C-Otto/rebalance-lnd.git \ 6 | && rm -rf rebalance-lnd/.github \ 7 | && cd rebalance-lnd \ 8 | && /usr/local/bin/python -m pip install --upgrade pip \ 9 | && pip install -r requirements.txt \ 10 | && cd / \ 11 | && apk del linux-headers gcc g++ git openssh-client \ 12 | && mkdir /root/.lnd 13 | 14 | VOLUME [ "/root/.lnd" ] 15 | 16 | WORKDIR / 17 | 18 | ENTRYPOINT [ "/rebalance-lnd/rebalance.py" ] 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Carsten Otto 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | up: ## Docker compose up, start container 2 | docker compose up -d --build 3 | down: ## Docker compose down 4 | docker compose down --remove-orphans 5 | shell: ## Shell into container 6 | docker compose exec rebalance-lnd sh 7 | test: ## Run tests 8 | docker compose exec rebalance-lnd python -m unittest discover tests/ -v 9 | 10 | .PHONY: help 11 | help: 12 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 13 | 14 | .DEFAULT_GOAL := help 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rebalance-lnd 2 | 3 | Using this script you can easily rebalance individual channels of your `lnd` node by 4 | sending funds out one channel, through the lightning network, back to yourself. 5 | 6 | This script helps you move funds between your channels so that you can increase the outbound liquidity in one channel, 7 | while decreasing the outbound liquidity in another channel. 8 | This way you can, for example, make sure that all of your channels have enough outbound liquidity to send/route 9 | transactions. 10 | 11 | Because you are both the sender and the receiver of the corresponding transactions, you only have to pay for routing 12 | fees. 13 | Aside from paid fees, your total liquidity does not change. 14 | 15 | ## Installation 16 | 17 | There are several options for installing rebalance-lnd. 18 | 19 | 1. [Using Python](#using-python) 20 | 1. [Using Docker](#using-docker) 21 | 1. [Using Umbrel's app store](#using-umbrels-app-store) 22 | 1. [Using the RaspiBolt manual installation guide on any Debian-based OS](#using-the-raspibolt-guide) 23 | 24 | ### Using Python 25 | 26 | #### lnd 27 | 28 | This script needs an active `lnd` (tested with v0.17.5, https://github.com/lightningnetwork/lnd) instance running. 29 | If you compile `lnd` yourself, you need to include the `routerrpc` build tag: 30 | 31 | Example: 32 | `make tags="autopilotrpc signrpc walletrpc chainrpc invoicesrpc routerrpc"` 33 | 34 | You need to have admin rights to control this node. 35 | By default, this script connects to `localhost:10009`, using the macaroon file in `~/.lnd/data/chain/bitcoin/mainnet/admin.macaroon`. 36 | If this does not help, it also tries to find the file in `~/umbrel/lnd/data/chain/bitcoin/mainnet/admin.macaroon` 37 | (or `~/umbrel/app-data/lightning/data/lnd/data/chain/bitcoin/mainnet/admin.macaroon`). 38 | If you need to change this, please have a look at the optional arguments `--grpc` and `--lnddir`. 39 | 40 | #### rebalance-lnd itself 41 | 42 | You need to download the files that are part of this project, for example using [git](https://git-scm.com/): 43 | 44 | ```sh 45 | cd /some/where/ 46 | git clone https://github.com/C-Otto/rebalance-lnd.git 47 | cd rebalance-lnd/ 48 | ``` 49 | 50 | Alternatively, you may also download the files in a ZIP file offered by GitHub: 51 | https://github.com/C-Otto/rebalance-lnd/archive/refs/heads/main.zip 52 | 53 | #### Python Dependencies 54 | 55 | You need to install Python 3. You also need to install the gRPC dependencies which can be done by running: 56 | 57 | ```sh 58 | pip install -r requirements.txt 59 | ``` 60 | 61 | If this fails, make sure you are running Python 3. You might want to try `pip3` instead of `pip`. 62 | 63 | To test if your installation works, you can run `rebalance.py` without any arguments. 64 | Depending on your system, you can do this in one of the following ways: 65 | 66 | - `python3 rebalance.py` 67 | - `./rebalance.py` 68 | - `python rebalance.py` 69 | 70 | ### Using Docker 71 | 72 | Using the containerized version of rebalance-lnd spares you from the installation of python and its dependencies. 73 | You start by fetching the latest version of the dedicated docker container. 74 | 75 | ```sh 76 | docker pull rebalancelnd/rebalance-lnd:latest 77 | ``` 78 | 79 | You can now have the docker image interact with your lnd installation: 80 | 81 | ```sh 82 | docker run --rm --network=host --add-host=host.docker.internal:host-gateway -it -v /home/lnd:/root/.lnd rebalancelnd/rebalance-lnd --grpc host.docker.internal:10009 83 | ``` 84 | 85 | The above command assumes `/home/lnd` is your lnd configuration directory. Please adjust as required. 86 | 87 | #### Note for Umbrel/Umbrel-OS users 88 | 89 | To inject rebalance-lnd into your umbrel network you can run it using the following command line: 90 | 91 | ```sh 92 | docker run --rm --network=umbrel_main_network -it -v /home/umbrel/umbrel/app-data/lightning/data/lnd:/root/.lnd rebalancelnd/rebalance-lnd --grpc 10.21.21.9:10009 93 | ``` 94 | 95 | Optionally you can create an alias in your shell's environment file like so: 96 | 97 | ```sh 98 | alias rebalance-lnd="docker run --rm --network=umbrel_main_network -it -v /home/umbrel/umbrel/app-data/lightning/data/lnd:/root/.lnd rebalancelnd/rebalance-lnd --grpc 10.21.21.9:10009" 99 | ``` 100 | 101 | For older versions of Umbrel please use `/home/umbrel/umbrel/lnd` instead of `/home/umbrel/umbrel/app-data/lightning/data/lnd`. 102 | 103 | ### Using Umbrel's app store 104 | 105 | The [`lightning-shell`](https://github.com/ibz/lightning-shell) app available in the Umbrel app store comes with rebalance-lnd installed and configured. It should just work out of the box! 106 | 107 | #### Note for BTCPayServer Users 108 | 109 | To inject rebalance-lnd into your BTCPayServer network you can run it using the following command line: 110 | 111 | ```sh 112 | docker run --rm --network=generated_default -it -v /var/lib/docker/volumes/generated_lnd_bitcoin_datadir/_data:/root/.lnd rebalancelnd/rebalance-lnd --grpc lnd_bitcoin:10009 113 | ``` 114 | 115 | Optionally you can create an alias in your shell's environment file like so: 116 | 117 | ```sh 118 | alias rebalance-lnd="docker run --rm --network=generated_default -it -v /var/lib/docker/volumes/generated_lnd_bitcoin_datadir/_data:/root/.lnd rebalancelnd/rebalance-lnd --grpc lnd_bitcoin:10009" 119 | ``` 120 | 121 | ## Updating 122 | 123 | If you use docker, update the image running `docker pull rebalancelnd/rebalance-lnd:latest` again, otherwise follow these steps to update the python version: 124 | 125 | If you already have a version of `rebalance-lnd` checked out via `git`, you can just use `git pull` to update to the 126 | latest version. You may also delete everything and start over with a fresh installation, as the script does not store 127 | any data that needs to be kept. 128 | 129 | Do not forget to update the Python dependencies as described above. 130 | 131 | ### Using the RaspiBolt guide 132 | 133 | If you run a node on a Debian-based OS, you can follow the [RaspiBolt guide](https://raspibolt.org/guide/bonus/lightning/rebalance-lnd.html) that explains how to manually install, use, update and uninstall rebalance-lnd on your node. 134 | 135 | # Usage 136 | 137 | ### List of channels 138 | 139 | Run `rebalance.py -l` (or `rebalance.py -l -i`) to see a list of channels which can be rebalanced. 140 | This list only contains channels where you should increase the outbound liquidity (you can specify `--show-all` to see 141 | all channels). 142 | 143 | You can also see the list of channels where the inbound liquidity should be increased by running `rebalance.py -l -o`. 144 | 145 | As an example the following indicates a channel with around 17.7% of the funds 146 | on the local side: 147 | 148 | ```sh 149 | Channel ID: 11111111 150 | Alias: The Best Node Ever 151 | Pubkey: 012345[...]abcdef 152 | Channel Point: abc0123[...]abc:0 153 | Local ratio: 0.176 154 | Fee rates: 123ppm (own), 456ppm (peer) 155 | Capacity: 5,000,000 156 | Remote available: 4,110,320 157 | Local available: 883,364 158 | Rebalance amount: 116,636 159 | [█████░░░░░░░░░░░░░░░░░░░░░░░] 160 | ``` 161 | 162 | By sending 116,636 satoshis to yourself using this channel, an outbound liquidity of 1,000,000 satoshis can be achieved. 163 | This number is shown as "Rebalance amount" (where negative amounts indicate that you need to increase your inbound 164 | liquidity). 165 | 166 | The last line shows a graphical representation of the channel. 167 | The total width is determined by the channel's capacity, where your largest channel (maximum capacity) occupies the full 168 | width of your terminal. 169 | The bar (`█`) indicates the funds on the local side of the channel, i.e. your outbound liquidity. 170 | 171 | ## Rebalancing a channel 172 | 173 | ### Basic Scenario 174 | 175 | In this scenario you already know what you want to do, possibly because you identified a channel with high 176 | outbound liquidity. 177 | 178 | Let us assume you want to move some funds from this channel to another channel with low outbound 179 | liquidity: 180 | 181 | - move 100,000 satoshis around 182 | - take those funds (plus fees) out of channel `11111111` 183 | - move those funds back into channel `22222222` 184 | 185 | You can achieve this by running: 186 | 187 | ```sh 188 | rebalance.py --amount 100000 --from 11111111 --to 22222222 189 | ``` 190 | 191 | The script now tries to find a route through the lightning network that starts with channel `11111111` and ends with 192 | channel `22222222`. If successful, you take 100,000 satoshis (plus fees) out of channel `11111111` (which means that you 193 | decrease your outbound liquidity and increase your inbound liquidity). In return, you get 100,000 satoshis back through 194 | channel `22222222` (which means that you increase your outbound liquidity and decrease your inbound liquidity). 195 | 196 | ### Automatically determined amount 197 | 198 | If you do not specify the amount, i.e. you invoke `rebalance.py --from 11111111 --to 22222222`, the script 199 | automatically determines the amount. 200 | For this two main constraints are taken into consideration: 201 | 202 | After the rebalance transaction is finished, 203 | 204 | - the destination channel (`22222222`) should have (up to) 1,000,000 satoshis outbound liquidity 205 | - the source channel (`11111111`) should have (at least) 1,000,000 satoshis outbound liquidity 206 | 207 | If the source channel has enough surplus outbound liquidity, the script constructs a transaction that ensures 208 | 1,000,000 satoshis of outbound liquidity in the destination channel. 209 | However, if the source channel does not have enough outbound liquidity, the amount is determined so that (after the 210 | rebalance transaction is performed) the source channel has 1,000,000 satoshis of outbound liquidity and the destination 211 | channel has more outbound liquidity than before (but not necessarily 1,000,000 satoshis). 212 | 213 | Note that for smaller channels these criteria cannot be met. 214 | If that is the case, a ratio of 50% is targeted instead: the destination channel receives up to 50% outbound 215 | liquidity, and for the sending channel at least 50% outbound liquidity are maintained. 216 | 217 | ### Limiting the automatically determined amount 218 | 219 | If you use both `-a` to specify an amount and `-A` (shorthand for `--adjust-amount-to-limits`), the computed amount is 220 | adjusted to the given amount. If, for example, you send funds to a channel that lacks 500,000sat to reach the 221 | `--min-local` value, the computed amount is 500,000sat. If you invoke the script with `-A -a 90000` this amount is 222 | reduced to 90,000sat. If the adjusted amount is below the `--min-amount` setting, the script stops. 223 | 224 | This way, using both options (`-a xxx -A`) you can start a rebalance attempt with the given amount, which will only be 225 | attempted if it is necessary. Thus, you may want to run `./rebalance.py -t xxx -A -a 50000` in a loop or cron job to 226 | automatically send up to 50,000sat to channel `xxx` until it has reached the `--min-local` limit. If the channel already 227 | satisfies the `--min-local` limit, the script exits and does not attempt to send any funds. 228 | 229 | ### Only specifying one channel 230 | 231 | Instead of specifying both `--from` and `--to`, you can also just pick one of those options. 232 | The script then considers all of your other channels as possible "partners", still taking into account the constraints 233 | described above. 234 | 235 | As an example, if you run `rebalance.py --amount 100000 --to 22222222`, the script tries to find source channels 236 | that, after sending 100,000 satoshis (plus fees), still have at least 1,000,000 satoshis of outbound liquidity. 237 | In other words, the script does not send funds from already "empty" channels. 238 | 239 | Likewise, when only specifying `--from`, the script only considers target channels which still have at least 1,000,000 240 | satoshis of outbound liquidity after receiving the payment. 241 | 242 | If you also let the script determine the amount, e.g. you run `rebalance.py --to 22222222`, the script first 243 | computes the amount that is necessary to reach 1,000,000 satoshis of outbound liquidity in channel `22222222`, 244 | and then tries to find source channels for the computed amount. 245 | 246 | ### Safety Checks and Limitations 247 | 248 | Note that, by default, nothing is done if the amount (either given or computed) is smaller than 10,000 satoshis. 249 | You can change this number using `--min-amount`. 250 | 251 | Furthermore, a rebalance transaction is only sent if it is economically viable as described below. 252 | This way, by default, you only send rebalance transactions that improve your node's situation, for example by providing 253 | outbound liquidity to channels where you charge a lot, and taking those funds out of channels where you charge less. 254 | 255 | To protect you from making mistakes, in the fee computation (described below) the fee rate of the destination channel 256 | is capped at 2,000ppm (which corresponds to a 0.2% fee), even if the channel is configured with a higher fee rate. 257 | 258 | ## Fees 259 | 260 | In order for the network to route your rebalance transaction, you have to pay fees. 261 | In addition to the fees charged by the nodes routing your transaction, two other values are taken into account: 262 | 263 | 1. The fee you would earn if, instead of sending the funds out of the source channel as part of the rebalance transaction, your node is paid to forward the amount 264 | 2. The fee you would earn if, after the rebalance transaction is done, your node forwards the amount through the destination channel 265 | 266 | The first bullet point describes opportunity/implicit costs. 267 | If you take lots of funds out of the source channel, you cannot use those funds to earn routing fees via that channel. 268 | As such, taking funds out of a channel where you configured a high fee rate can be considered costly. 269 | 270 | The second bullet points describes future earnings. 271 | If you send funds to the destination channel and increase your outbound liquidity in that channel, you might earn fees 272 | for routing those funds. 273 | As such, increasing the outbound liquidity of the destination is a good idea, assuming the fee rate configured for the 274 | destination channel is higher than the fee rate you configured for the source channel. 275 | However, keep in mind that you will only earn those fees if your node actually forwards the funds via the channel! 276 | 277 | The rebalance transaction is not performed if the transaction fees plus the implicit costs (1) are higher than the 278 | possible future earnings (2). 279 | 280 | **If you really want to, you may disable these safety checks with `--reckless`.** 281 | 282 | ### Example 283 | 284 | You have lots of funds in channel `11111111` and nothing in channel `22222222`. 285 | You would like to send funds through channel `11111111` (source channel) through the lightning network, and finally 286 | back into channel `22222222`. 287 | This incurs a transaction fee you would have to pay for the transaction. 288 | 289 | Furthermore, if in the future there is demand for your node to route funds 290 | through channel `11111111`, you cannot do that as much (because you decreased your outbound liquidity in the channel). 291 | The associated fees are the implicit cost (1). 292 | 293 | Finally, you send funds into channel `22222222` in the hope that later on someone requests your node to forward funds 294 | from your own node through channel `22222222` towards the peer at the other end, so that you can earn fees for this. 295 | These fees are the possible future income (2). 296 | 297 | ### Fee Factor 298 | 299 | The value set with `--fee-factor` is used to scale the future income used in the computation outlined above. 300 | As such, you can fool the script into believing that the fee rate configured for the destination channel is 301 | higher (fee factor > 1) or lower (fee factor < 1) than it actually is. 302 | 303 | As such, if you set `--fee-factor` to a value higher than 1, more routes are considered. 304 | As an example, with `--fee-factor 1.5` you can include routes that cost up to 150% of the future income. 305 | 306 | With values smaller than 1 only cheaper routes are considered. 307 | 308 | ### Fee Limit 309 | 310 | Unrelated to `--fee-factor` (which is the default, with a value of 1), you can also specify an absolute fee 311 | limit using `--fee-limit`. If you decide to do so, only routes that cost up to the given number (in satoshis) are 312 | considered. 313 | 314 | Note that the script rejects routes/channels that are deemed uneconomical based on the configured fee rates 315 | (i.e. with `--fee-factor` set to 1) (as explained above). 316 | 317 | ### Fee Rate (ppm) Limit 318 | 319 | You can use `--fee-ppm-limit` as another alternative to specify a fee limit. 320 | In this case the amount sent as part of the rebalance is considered, so that the fee is at most 321 | 322 | ```sh 323 | amount * fee-ppm-limit / 1_000_000 324 | ``` 325 | 326 | Note that the script rejects routes/channels that are deemed uneconomical based on the configured fee rates 327 | (i.e. with `--fee-factor` set to 1) (as explained above). 328 | 329 | ### Warning 330 | 331 | To determine the future income, the fee rate you configured for the destination channel is used in the computation. 332 | As such, if you set an unrealistic fee rate that will not lead to forward transactions, you would allow more expensive 333 | rebalance transactions without earning anything in return. 334 | Please make sure to set realistic fee rates, which at best are already known to attract forwardings. 335 | 336 | ### Command line arguments 337 | 338 | ```sh 339 | usage: rebalance.py [-h] [--lnddir LNDDIR] [--network NETWORK] [--grpc GRPC] 340 | [-l] [--show-all | --show-only CHANNEL | -c] [-o | -i] 341 | [-f CHANNEL] [-t CHANNEL] [-A] [-a AMOUNT | -p PERCENTAGE] 342 | [--min-amount MIN_AMOUNT] [--min-local MIN_LOCAL] 343 | [--min-remote MIN_REMOTE] [-e EXCLUDE] [--reckless] 344 | [--ignore-missed-fee] [--fee-factor FEE_FACTOR] 345 | [--fee-limit FEE_LIMIT | --fee-ppm-limit FEE_PPM_LIMIT] 346 | 347 | optional arguments: 348 | -h, --help show this help message and exit 349 | --lnddir LNDDIR (default ~/.lnd) lnd directory 350 | --network NETWORK (default mainnet) lnd network (mainnet, testnet, 351 | simnet, ...) 352 | --grpc GRPC (default localhost:10009) lnd gRPC endpoint 353 | 354 | list candidates: 355 | Show the unbalanced channels. 356 | 357 | -l, --list-candidates 358 | list candidate channels for rebalance 359 | --show-all also show channels with zero rebalance amount 360 | --show-only CHANNEL only show information about the given channel 361 | -c, --compact Shows a compact list of all channels, one per line 362 | including ID, inbound/outbound liquidity, and alias 363 | -o, --outgoing lists channels with less than 1,000,000 (--min-remote) 364 | satoshis inbound liquidity 365 | -i, --incoming (default) lists channels with less than 1,000,000 366 | (--min-local) satoshis outbound liquidity 367 | 368 | rebalance: 369 | Rebalance a channel. You need to specify at least the 'from' channel (-f) 370 | or the 'to' channel (-t). 371 | 372 | -f CHANNEL, --from CHANNEL 373 | Channel ID of the outgoing channel (funds will be 374 | taken from this channel). You may also specify the ID 375 | using the colon notation (12345:12:1), or the x 376 | notation (12345x12x1). You may also use -1 to choose a 377 | random candidate. 378 | -t CHANNEL, --to CHANNEL 379 | Channel ID of the incoming channel (funds will be sent 380 | to this channel). You may also specify the ID using 381 | the colon notation (12345:12:1), or the x notation 382 | (12345x12x1). You may also use -1 to choose a random 383 | candidate. 384 | -A, --adjust-amount-to-limits 385 | If set, adjust the amount to the limits (--min-local 386 | and --min-remote). The script will exit if the 387 | adjusted amount is below the --min-amount threshold. 388 | As such, this switch can be used if you do NOT want to 389 | rebalance if the channel is within the limits. 390 | -a AMOUNT, --amount AMOUNT 391 | Amount of the rebalance, in satoshis. If not 392 | specified, the amount computed for a perfect rebalance 393 | will be used 394 | -p PERCENTAGE, --percentage PERCENTAGE 395 | Set the amount to a percentage of the computed amount. 396 | As an example, if this is set to 50, half of the 397 | computed amount will be used. See --amount. 398 | --min-amount MIN_AMOUNT 399 | (Default: 10,000) If the given or computed rebalance 400 | amount is below this limit, nothing is done. 401 | --min-local MIN_LOCAL 402 | (Default: 1,000,000) Ensure that the channels have at 403 | least this amount as outbound liquidity. 404 | --min-remote MIN_REMOTE 405 | (Default: 1,000,000) Ensure that the channels have at 406 | least this amount as inbound liquidity. 407 | -e EXCLUDE, --exclude EXCLUDE 408 | Exclude the given channel. Can be used multiple times. 409 | --exclude-private Exclude private channels. This will not affect channel 410 | ID used at --to and/or --from but will take effect if 411 | you used -1 to get a random channel. 412 | --reckless Allow rebalance transactions that are not economically 413 | viable. You might also want to set --min-local 0 and 414 | --min-remote 0. If set, you also need to set --amount 415 | and either --fee-limit or --fee-ppm-limit, and you 416 | must not enable --adjust-amount-to-limits (-A). 417 | --ignore-missed-fee Ignore missed fee from source channel. 418 | --fee-factor FEE_FACTOR 419 | (default: 1.0) Compare the costs against the expected 420 | income, scaled by this factor. As an example, with 421 | --fee-factor 1.5, routes that cost at most 150% of the 422 | expected earnings are tried. Use values smaller than 423 | 1.0 to restrict routes to only consider those earning 424 | more/costing less. This factor is ignored with 425 | --reckless. 426 | --fee-limit FEE_LIMIT 427 | If set, only consider rebalance transactions that cost 428 | up to the given number of satoshis. 429 | --fee-ppm-limit FEE_PPM_LIMIT 430 | If set, only consider rebalance transactions that cost 431 | up to the given number of satoshis per 1M satoshis 432 | sent. 433 | ``` 434 | 435 | ## Contributing 436 | 437 | Contributions are highly welcome! 438 | Feel free to submit issues and pull requests on https://github.com/C-Otto/rebalance-lnd/ 439 | 440 | You can also send donations via keysend. 441 | For example, to send 500 satoshis to C-Otto with a message "Thank you for rebalance-lnd": 442 | 443 | ```sh 444 | lncli sendpayment --amt=500 --data 7629168=5468616e6b20796f7520666f7220726562616c616e63652d6c6e64 --keysend --dest=027ce055380348d7812d2ae7745701c9f93e70c1adeb2657f053f91df4f2843c71 445 | ``` 446 | 447 | You can also specify an arbitrary message: 448 | 449 | ```sh 450 | lncli sendpayment --amt=500 --data 7629168=$(echo -n "your message here" | xxd -pu -c 10000) --keysend --dest=027ce055380348d7812d2ae7745701c9f93e70c1adeb2657f053f91df4f2843c71 451 | ``` 452 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | rebalance-lnd: 3 | container_name: rebalance-lnd 4 | build: 5 | context: . 6 | dockerfile: test.Dockerfile 7 | volumes: 8 | - .:/code 9 | working_dir: /code 10 | environment: 11 | - PYTHONDONTWRITEBYTECODE=1 12 | restart: unless-stopped 13 | entrypoint: ["sleep", "infinity"] 14 | -------------------------------------------------------------------------------- /grpc_generated/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/C-Otto/rebalance-lnd/9120c93e48cfdf99e7e459309da2358c4b774bd2/grpc_generated/__init__.py -------------------------------------------------------------------------------- /grpc_generated/invoices_pb2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by the protocol buffer compiler. DO NOT EDIT! 3 | # source: invoices.proto 4 | # Protobuf Python Version: 5.26.1 5 | """Generated protocol buffer code.""" 6 | from google.protobuf import descriptor as _descriptor 7 | from google.protobuf import descriptor_pool as _descriptor_pool 8 | from google.protobuf import symbol_database as _symbol_database 9 | from google.protobuf.internal import builder as _builder 10 | # @@protoc_insertion_point(imports) 11 | 12 | _sym_db = _symbol_database.Default() 13 | 14 | 15 | from grpc_generated import lightning_pb2 as lightning__pb2 16 | 17 | 18 | DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0einvoices.proto\x12\x0binvoicesrpc\x1a\x0flightning.proto\"(\n\x10\x43\x61ncelInvoiceMsg\x12\x14\n\x0cpayment_hash\x18\x01 \x01(\x0c\"\x13\n\x11\x43\x61ncelInvoiceResp\"\xe4\x01\n\x15\x41\x64\x64HoldInvoiceRequest\x12\x0c\n\x04memo\x18\x01 \x01(\t\x12\x0c\n\x04hash\x18\x02 \x01(\x0c\x12\r\n\x05value\x18\x03 \x01(\x03\x12\x12\n\nvalue_msat\x18\n \x01(\x03\x12\x18\n\x10\x64\x65scription_hash\x18\x04 \x01(\x0c\x12\x0e\n\x06\x65xpiry\x18\x05 \x01(\x03\x12\x15\n\rfallback_addr\x18\x06 \x01(\t\x12\x13\n\x0b\x63ltv_expiry\x18\x07 \x01(\x04\x12%\n\x0broute_hints\x18\x08 \x03(\x0b\x32\x10.lnrpc.RouteHint\x12\x0f\n\x07private\x18\t \x01(\x08\"V\n\x12\x41\x64\x64HoldInvoiceResp\x12\x17\n\x0fpayment_request\x18\x01 \x01(\t\x12\x11\n\tadd_index\x18\x02 \x01(\x04\x12\x14\n\x0cpayment_addr\x18\x03 \x01(\x0c\"$\n\x10SettleInvoiceMsg\x12\x10\n\x08preimage\x18\x01 \x01(\x0c\"\x13\n\x11SettleInvoiceResp\"5\n\x1dSubscribeSingleInvoiceRequest\x12\x0e\n\x06r_hash\x18\x02 \x01(\x0cJ\x04\x08\x01\x10\x02\"\x99\x01\n\x10LookupInvoiceMsg\x12\x16\n\x0cpayment_hash\x18\x01 \x01(\x0cH\x00\x12\x16\n\x0cpayment_addr\x18\x02 \x01(\x0cH\x00\x12\x10\n\x06set_id\x18\x03 \x01(\x0cH\x00\x12\x34\n\x0flookup_modifier\x18\x04 \x01(\x0e\x32\x1b.invoicesrpc.LookupModifierB\r\n\x0binvoice_ref*D\n\x0eLookupModifier\x12\x0b\n\x07\x44\x45\x46\x41ULT\x10\x00\x12\x11\n\rHTLC_SET_ONLY\x10\x01\x12\x12\n\x0eHTLC_SET_BLANK\x10\x02\x32\x9b\x03\n\x08Invoices\x12V\n\x16SubscribeSingleInvoice\x12*.invoicesrpc.SubscribeSingleInvoiceRequest\x1a\x0e.lnrpc.Invoice0\x01\x12N\n\rCancelInvoice\x12\x1d.invoicesrpc.CancelInvoiceMsg\x1a\x1e.invoicesrpc.CancelInvoiceResp\x12U\n\x0e\x41\x64\x64HoldInvoice\x12\".invoicesrpc.AddHoldInvoiceRequest\x1a\x1f.invoicesrpc.AddHoldInvoiceResp\x12N\n\rSettleInvoice\x12\x1d.invoicesrpc.SettleInvoiceMsg\x1a\x1e.invoicesrpc.SettleInvoiceResp\x12@\n\x0fLookupInvoiceV2\x12\x1d.invoicesrpc.LookupInvoiceMsg\x1a\x0e.lnrpc.InvoiceB3Z1github.com/lightningnetwork/lnd/lnrpc/invoicesrpcb\x06proto3') 19 | 20 | _globals = globals() 21 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) 22 | _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'invoices_pb2', _globals) 23 | if not _descriptor._USE_C_DESCRIPTORS: 24 | _globals['DESCRIPTOR']._loaded_options = None 25 | _globals['DESCRIPTOR']._serialized_options = b'Z1github.com/lightningnetwork/lnd/lnrpc/invoicesrpc' 26 | _globals['_LOOKUPMODIFIER']._serialized_start=700 27 | _globals['_LOOKUPMODIFIER']._serialized_end=768 28 | _globals['_CANCELINVOICEMSG']._serialized_start=48 29 | _globals['_CANCELINVOICEMSG']._serialized_end=88 30 | _globals['_CANCELINVOICERESP']._serialized_start=90 31 | _globals['_CANCELINVOICERESP']._serialized_end=109 32 | _globals['_ADDHOLDINVOICEREQUEST']._serialized_start=112 33 | _globals['_ADDHOLDINVOICEREQUEST']._serialized_end=340 34 | _globals['_ADDHOLDINVOICERESP']._serialized_start=342 35 | _globals['_ADDHOLDINVOICERESP']._serialized_end=428 36 | _globals['_SETTLEINVOICEMSG']._serialized_start=430 37 | _globals['_SETTLEINVOICEMSG']._serialized_end=466 38 | _globals['_SETTLEINVOICERESP']._serialized_start=468 39 | _globals['_SETTLEINVOICERESP']._serialized_end=487 40 | _globals['_SUBSCRIBESINGLEINVOICEREQUEST']._serialized_start=489 41 | _globals['_SUBSCRIBESINGLEINVOICEREQUEST']._serialized_end=542 42 | _globals['_LOOKUPINVOICEMSG']._serialized_start=545 43 | _globals['_LOOKUPINVOICEMSG']._serialized_end=698 44 | _globals['_INVOICES']._serialized_start=771 45 | _globals['_INVOICES']._serialized_end=1182 46 | # @@protoc_insertion_point(module_scope) 47 | -------------------------------------------------------------------------------- /grpc_generated/invoices_pb2_grpc.py: -------------------------------------------------------------------------------- 1 | # Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! 2 | """Client and server classes corresponding to protobuf-defined services.""" 3 | import grpc 4 | import warnings 5 | 6 | from grpc_generated import invoices_pb2 as invoices__pb2 7 | from grpc_generated import lightning_pb2 as lightning__pb2 8 | 9 | GRPC_GENERATED_VERSION = '1.63.0' 10 | GRPC_VERSION = grpc.__version__ 11 | EXPECTED_ERROR_RELEASE = '1.65.0' 12 | SCHEDULED_RELEASE_DATE = 'June 25, 2024' 13 | _version_not_supported = False 14 | 15 | try: 16 | from grpc._utilities import first_version_is_lower 17 | _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) 18 | except ImportError: 19 | _version_not_supported = True 20 | 21 | if _version_not_supported: 22 | warnings.warn( 23 | f'The grpc package installed is at version {GRPC_VERSION},' 24 | + f' but the generated code in invoices_pb2_grpc.py depends on' 25 | + f' grpcio>={GRPC_GENERATED_VERSION}.' 26 | + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' 27 | + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' 28 | + f' This warning will become an error in {EXPECTED_ERROR_RELEASE},' 29 | + f' scheduled for release on {SCHEDULED_RELEASE_DATE}.', 30 | RuntimeWarning 31 | ) 32 | 33 | 34 | class InvoicesStub(object): 35 | """ 36 | Comments in this file will be directly parsed into the API 37 | Documentation as descriptions of the associated method, message, or field. 38 | These descriptions should go right above the definition of the object, and 39 | can be in either block or // comment format. 40 | 41 | An RPC method can be matched to an lncli command by placing a line in the 42 | beginning of the description in exactly the following format: 43 | lncli: `methodname` 44 | 45 | Failure to specify the exact name of the command will cause documentation 46 | generation to fail. 47 | 48 | More information on how exactly the gRPC documentation is generated from 49 | this proto file can be found here: 50 | https://github.com/lightninglabs/lightning-api 51 | 52 | Invoices is a service that can be used to create, accept, settle and cancel 53 | invoices. 54 | """ 55 | 56 | def __init__(self, channel): 57 | """Constructor. 58 | 59 | Args: 60 | channel: A grpc.Channel. 61 | """ 62 | self.SubscribeSingleInvoice = channel.unary_stream( 63 | '/invoicesrpc.Invoices/SubscribeSingleInvoice', 64 | request_serializer=invoices__pb2.SubscribeSingleInvoiceRequest.SerializeToString, 65 | response_deserializer=lightning__pb2.Invoice.FromString, 66 | _registered_method=True) 67 | self.CancelInvoice = channel.unary_unary( 68 | '/invoicesrpc.Invoices/CancelInvoice', 69 | request_serializer=invoices__pb2.CancelInvoiceMsg.SerializeToString, 70 | response_deserializer=invoices__pb2.CancelInvoiceResp.FromString, 71 | _registered_method=True) 72 | self.AddHoldInvoice = channel.unary_unary( 73 | '/invoicesrpc.Invoices/AddHoldInvoice', 74 | request_serializer=invoices__pb2.AddHoldInvoiceRequest.SerializeToString, 75 | response_deserializer=invoices__pb2.AddHoldInvoiceResp.FromString, 76 | _registered_method=True) 77 | self.SettleInvoice = channel.unary_unary( 78 | '/invoicesrpc.Invoices/SettleInvoice', 79 | request_serializer=invoices__pb2.SettleInvoiceMsg.SerializeToString, 80 | response_deserializer=invoices__pb2.SettleInvoiceResp.FromString, 81 | _registered_method=True) 82 | self.LookupInvoiceV2 = channel.unary_unary( 83 | '/invoicesrpc.Invoices/LookupInvoiceV2', 84 | request_serializer=invoices__pb2.LookupInvoiceMsg.SerializeToString, 85 | response_deserializer=lightning__pb2.Invoice.FromString, 86 | _registered_method=True) 87 | 88 | 89 | class InvoicesServicer(object): 90 | """ 91 | Comments in this file will be directly parsed into the API 92 | Documentation as descriptions of the associated method, message, or field. 93 | These descriptions should go right above the definition of the object, and 94 | can be in either block or // comment format. 95 | 96 | An RPC method can be matched to an lncli command by placing a line in the 97 | beginning of the description in exactly the following format: 98 | lncli: `methodname` 99 | 100 | Failure to specify the exact name of the command will cause documentation 101 | generation to fail. 102 | 103 | More information on how exactly the gRPC documentation is generated from 104 | this proto file can be found here: 105 | https://github.com/lightninglabs/lightning-api 106 | 107 | Invoices is a service that can be used to create, accept, settle and cancel 108 | invoices. 109 | """ 110 | 111 | def SubscribeSingleInvoice(self, request, context): 112 | """ 113 | SubscribeSingleInvoice returns a uni-directional stream (server -> client) 114 | to notify the client of state transitions of the specified invoice. 115 | Initially the current invoice state is always sent out. 116 | """ 117 | context.set_code(grpc.StatusCode.UNIMPLEMENTED) 118 | context.set_details('Method not implemented!') 119 | raise NotImplementedError('Method not implemented!') 120 | 121 | def CancelInvoice(self, request, context): 122 | """lncli: `cancelinvoice` 123 | CancelInvoice cancels a currently open invoice. If the invoice is already 124 | canceled, this call will succeed. If the invoice is already settled, it will 125 | fail. 126 | """ 127 | context.set_code(grpc.StatusCode.UNIMPLEMENTED) 128 | context.set_details('Method not implemented!') 129 | raise NotImplementedError('Method not implemented!') 130 | 131 | def AddHoldInvoice(self, request, context): 132 | """lncli: `addholdinvoice` 133 | AddHoldInvoice creates a hold invoice. It ties the invoice to the hash 134 | supplied in the request. 135 | """ 136 | context.set_code(grpc.StatusCode.UNIMPLEMENTED) 137 | context.set_details('Method not implemented!') 138 | raise NotImplementedError('Method not implemented!') 139 | 140 | def SettleInvoice(self, request, context): 141 | """lncli: `settleinvoice` 142 | SettleInvoice settles an accepted invoice. If the invoice is already 143 | settled, this call will succeed. 144 | """ 145 | context.set_code(grpc.StatusCode.UNIMPLEMENTED) 146 | context.set_details('Method not implemented!') 147 | raise NotImplementedError('Method not implemented!') 148 | 149 | def LookupInvoiceV2(self, request, context): 150 | """ 151 | LookupInvoiceV2 attempts to look up at invoice. An invoice can be refrenced 152 | using either its payment hash, payment address, or set ID. 153 | """ 154 | context.set_code(grpc.StatusCode.UNIMPLEMENTED) 155 | context.set_details('Method not implemented!') 156 | raise NotImplementedError('Method not implemented!') 157 | 158 | 159 | def add_InvoicesServicer_to_server(servicer, server): 160 | rpc_method_handlers = { 161 | 'SubscribeSingleInvoice': grpc.unary_stream_rpc_method_handler( 162 | servicer.SubscribeSingleInvoice, 163 | request_deserializer=invoices__pb2.SubscribeSingleInvoiceRequest.FromString, 164 | response_serializer=lightning__pb2.Invoice.SerializeToString, 165 | ), 166 | 'CancelInvoice': grpc.unary_unary_rpc_method_handler( 167 | servicer.CancelInvoice, 168 | request_deserializer=invoices__pb2.CancelInvoiceMsg.FromString, 169 | response_serializer=invoices__pb2.CancelInvoiceResp.SerializeToString, 170 | ), 171 | 'AddHoldInvoice': grpc.unary_unary_rpc_method_handler( 172 | servicer.AddHoldInvoice, 173 | request_deserializer=invoices__pb2.AddHoldInvoiceRequest.FromString, 174 | response_serializer=invoices__pb2.AddHoldInvoiceResp.SerializeToString, 175 | ), 176 | 'SettleInvoice': grpc.unary_unary_rpc_method_handler( 177 | servicer.SettleInvoice, 178 | request_deserializer=invoices__pb2.SettleInvoiceMsg.FromString, 179 | response_serializer=invoices__pb2.SettleInvoiceResp.SerializeToString, 180 | ), 181 | 'LookupInvoiceV2': grpc.unary_unary_rpc_method_handler( 182 | servicer.LookupInvoiceV2, 183 | request_deserializer=invoices__pb2.LookupInvoiceMsg.FromString, 184 | response_serializer=lightning__pb2.Invoice.SerializeToString, 185 | ), 186 | } 187 | generic_handler = grpc.method_handlers_generic_handler( 188 | 'invoicesrpc.Invoices', rpc_method_handlers) 189 | server.add_generic_rpc_handlers((generic_handler,)) 190 | 191 | 192 | # This class is part of an EXPERIMENTAL API. 193 | class Invoices(object): 194 | """ 195 | Comments in this file will be directly parsed into the API 196 | Documentation as descriptions of the associated method, message, or field. 197 | These descriptions should go right above the definition of the object, and 198 | can be in either block or // comment format. 199 | 200 | An RPC method can be matched to an lncli command by placing a line in the 201 | beginning of the description in exactly the following format: 202 | lncli: `methodname` 203 | 204 | Failure to specify the exact name of the command will cause documentation 205 | generation to fail. 206 | 207 | More information on how exactly the gRPC documentation is generated from 208 | this proto file can be found here: 209 | https://github.com/lightninglabs/lightning-api 210 | 211 | Invoices is a service that can be used to create, accept, settle and cancel 212 | invoices. 213 | """ 214 | 215 | @staticmethod 216 | def SubscribeSingleInvoice(request, 217 | target, 218 | options=(), 219 | channel_credentials=None, 220 | call_credentials=None, 221 | insecure=False, 222 | compression=None, 223 | wait_for_ready=None, 224 | timeout=None, 225 | metadata=None): 226 | return grpc.experimental.unary_stream( 227 | request, 228 | target, 229 | '/invoicesrpc.Invoices/SubscribeSingleInvoice', 230 | invoices__pb2.SubscribeSingleInvoiceRequest.SerializeToString, 231 | lightning__pb2.Invoice.FromString, 232 | options, 233 | channel_credentials, 234 | insecure, 235 | call_credentials, 236 | compression, 237 | wait_for_ready, 238 | timeout, 239 | metadata, 240 | _registered_method=True) 241 | 242 | @staticmethod 243 | def CancelInvoice(request, 244 | target, 245 | options=(), 246 | channel_credentials=None, 247 | call_credentials=None, 248 | insecure=False, 249 | compression=None, 250 | wait_for_ready=None, 251 | timeout=None, 252 | metadata=None): 253 | return grpc.experimental.unary_unary( 254 | request, 255 | target, 256 | '/invoicesrpc.Invoices/CancelInvoice', 257 | invoices__pb2.CancelInvoiceMsg.SerializeToString, 258 | invoices__pb2.CancelInvoiceResp.FromString, 259 | options, 260 | channel_credentials, 261 | insecure, 262 | call_credentials, 263 | compression, 264 | wait_for_ready, 265 | timeout, 266 | metadata, 267 | _registered_method=True) 268 | 269 | @staticmethod 270 | def AddHoldInvoice(request, 271 | target, 272 | options=(), 273 | channel_credentials=None, 274 | call_credentials=None, 275 | insecure=False, 276 | compression=None, 277 | wait_for_ready=None, 278 | timeout=None, 279 | metadata=None): 280 | return grpc.experimental.unary_unary( 281 | request, 282 | target, 283 | '/invoicesrpc.Invoices/AddHoldInvoice', 284 | invoices__pb2.AddHoldInvoiceRequest.SerializeToString, 285 | invoices__pb2.AddHoldInvoiceResp.FromString, 286 | options, 287 | channel_credentials, 288 | insecure, 289 | call_credentials, 290 | compression, 291 | wait_for_ready, 292 | timeout, 293 | metadata, 294 | _registered_method=True) 295 | 296 | @staticmethod 297 | def SettleInvoice(request, 298 | target, 299 | options=(), 300 | channel_credentials=None, 301 | call_credentials=None, 302 | insecure=False, 303 | compression=None, 304 | wait_for_ready=None, 305 | timeout=None, 306 | metadata=None): 307 | return grpc.experimental.unary_unary( 308 | request, 309 | target, 310 | '/invoicesrpc.Invoices/SettleInvoice', 311 | invoices__pb2.SettleInvoiceMsg.SerializeToString, 312 | invoices__pb2.SettleInvoiceResp.FromString, 313 | options, 314 | channel_credentials, 315 | insecure, 316 | call_credentials, 317 | compression, 318 | wait_for_ready, 319 | timeout, 320 | metadata, 321 | _registered_method=True) 322 | 323 | @staticmethod 324 | def LookupInvoiceV2(request, 325 | target, 326 | options=(), 327 | channel_credentials=None, 328 | call_credentials=None, 329 | insecure=False, 330 | compression=None, 331 | wait_for_ready=None, 332 | timeout=None, 333 | metadata=None): 334 | return grpc.experimental.unary_unary( 335 | request, 336 | target, 337 | '/invoicesrpc.Invoices/LookupInvoiceV2', 338 | invoices__pb2.LookupInvoiceMsg.SerializeToString, 339 | lightning__pb2.Invoice.FromString, 340 | options, 341 | channel_credentials, 342 | insecure, 343 | call_credentials, 344 | compression, 345 | wait_for_ready, 346 | timeout, 347 | metadata, 348 | _registered_method=True) 349 | -------------------------------------------------------------------------------- /grpc_generated/router_pb2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by the protocol buffer compiler. DO NOT EDIT! 3 | # source: router.proto 4 | # Protobuf Python Version: 5.26.1 5 | """Generated protocol buffer code.""" 6 | from google.protobuf import descriptor as _descriptor 7 | from google.protobuf import descriptor_pool as _descriptor_pool 8 | from google.protobuf import symbol_database as _symbol_database 9 | from google.protobuf.internal import builder as _builder 10 | # @@protoc_insertion_point(imports) 11 | 12 | _sym_db = _symbol_database.Default() 13 | 14 | 15 | from grpc_generated import lightning_pb2 as lightning__pb2 16 | 17 | 18 | DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0crouter.proto\x12\trouterrpc\x1a\x0flightning.proto\"\xb7\x05\n\x12SendPaymentRequest\x12\x0c\n\x04\x64\x65st\x18\x01 \x01(\x0c\x12\x0b\n\x03\x61mt\x18\x02 \x01(\x03\x12\x14\n\x0cpayment_hash\x18\x03 \x01(\x0c\x12\x18\n\x10\x66inal_cltv_delta\x18\x04 \x01(\x05\x12\x17\n\x0fpayment_request\x18\x05 \x01(\t\x12\x17\n\x0ftimeout_seconds\x18\x06 \x01(\x05\x12\x15\n\rfee_limit_sat\x18\x07 \x01(\x03\x12\x1e\n\x10outgoing_chan_id\x18\x08 \x01(\x04\x42\x04\x18\x01\x30\x01\x12\x12\n\ncltv_limit\x18\t \x01(\x05\x12%\n\x0broute_hints\x18\n \x03(\x0b\x32\x10.lnrpc.RouteHint\x12Q\n\x13\x64\x65st_custom_records\x18\x0b \x03(\x0b\x32\x34.routerrpc.SendPaymentRequest.DestCustomRecordsEntry\x12\x10\n\x08\x61mt_msat\x18\x0c \x01(\x03\x12\x16\n\x0e\x66\x65\x65_limit_msat\x18\r \x01(\x03\x12\x17\n\x0flast_hop_pubkey\x18\x0e \x01(\x0c\x12\x1a\n\x12\x61llow_self_payment\x18\x0f \x01(\x08\x12(\n\rdest_features\x18\x10 \x03(\x0e\x32\x11.lnrpc.FeatureBit\x12\x11\n\tmax_parts\x18\x11 \x01(\r\x12\x1b\n\x13no_inflight_updates\x18\x12 \x01(\x08\x12\x19\n\x11outgoing_chan_ids\x18\x13 \x03(\x04\x12\x14\n\x0cpayment_addr\x18\x14 \x01(\x0c\x12\x1b\n\x13max_shard_size_msat\x18\x15 \x01(\x04\x12\x0b\n\x03\x61mp\x18\x16 \x01(\x08\x12\x11\n\ttime_pref\x18\x17 \x01(\x01\x1a\x38\n\x16\x44\x65stCustomRecordsEntry\x12\x0b\n\x03key\x18\x01 \x01(\x04\x12\r\n\x05value\x18\x02 \x01(\x0c:\x02\x38\x01\"H\n\x13TrackPaymentRequest\x12\x14\n\x0cpayment_hash\x18\x01 \x01(\x0c\x12\x1b\n\x13no_inflight_updates\x18\x02 \x01(\x08\"3\n\x14TrackPaymentsRequest\x12\x1b\n\x13no_inflight_updates\x18\x01 \x01(\x08\"Z\n\x0fRouteFeeRequest\x12\x0c\n\x04\x64\x65st\x18\x01 \x01(\x0c\x12\x0f\n\x07\x61mt_sat\x18\x02 \x01(\x03\x12\x17\n\x0fpayment_request\x18\x03 \x01(\t\x12\x0f\n\x07timeout\x18\x04 \x01(\r\"z\n\x10RouteFeeResponse\x12\x18\n\x10routing_fee_msat\x18\x01 \x01(\x03\x12\x17\n\x0ftime_lock_delay\x18\x02 \x01(\x03\x12\x33\n\x0e\x66\x61ilure_reason\x18\x05 \x01(\x0e\x32\x1b.lnrpc.PaymentFailureReason\"^\n\x12SendToRouteRequest\x12\x14\n\x0cpayment_hash\x18\x01 \x01(\x0c\x12\x1b\n\x05route\x18\x02 \x01(\x0b\x32\x0c.lnrpc.Route\x12\x15\n\rskip_temp_err\x18\x03 \x01(\x08\"H\n\x13SendToRouteResponse\x12\x10\n\x08preimage\x18\x01 \x01(\x0c\x12\x1f\n\x07\x66\x61ilure\x18\x02 \x01(\x0b\x32\x0e.lnrpc.Failure\"\x1c\n\x1aResetMissionControlRequest\"\x1d\n\x1bResetMissionControlResponse\"\x1c\n\x1aQueryMissionControlRequest\"J\n\x1bQueryMissionControlResponse\x12%\n\x05pairs\x18\x02 \x03(\x0b\x32\x16.routerrpc.PairHistoryJ\x04\x08\x01\x10\x02\"T\n\x1cXImportMissionControlRequest\x12%\n\x05pairs\x18\x01 \x03(\x0b\x32\x16.routerrpc.PairHistory\x12\r\n\x05\x66orce\x18\x02 \x01(\x08\"\x1f\n\x1dXImportMissionControlResponse\"o\n\x0bPairHistory\x12\x11\n\tnode_from\x18\x01 \x01(\x0c\x12\x0f\n\x07node_to\x18\x02 \x01(\x0c\x12$\n\x07history\x18\x07 \x01(\x0b\x32\x13.routerrpc.PairDataJ\x04\x08\x03\x10\x04J\x04\x08\x04\x10\x05J\x04\x08\x05\x10\x06J\x04\x08\x06\x10\x07\"\x99\x01\n\x08PairData\x12\x11\n\tfail_time\x18\x01 \x01(\x03\x12\x14\n\x0c\x66\x61il_amt_sat\x18\x02 \x01(\x03\x12\x15\n\rfail_amt_msat\x18\x04 \x01(\x03\x12\x14\n\x0csuccess_time\x18\x05 \x01(\x03\x12\x17\n\x0fsuccess_amt_sat\x18\x06 \x01(\x03\x12\x18\n\x10success_amt_msat\x18\x07 \x01(\x03J\x04\x08\x03\x10\x04\" \n\x1eGetMissionControlConfigRequest\"R\n\x1fGetMissionControlConfigResponse\x12/\n\x06\x63onfig\x18\x01 \x01(\x0b\x32\x1f.routerrpc.MissionControlConfig\"Q\n\x1eSetMissionControlConfigRequest\x12/\n\x06\x63onfig\x18\x01 \x01(\x0b\x32\x1f.routerrpc.MissionControlConfig\"!\n\x1fSetMissionControlConfigResponse\"\x93\x03\n\x14MissionControlConfig\x12\x1d\n\x11half_life_seconds\x18\x01 \x01(\x04\x42\x02\x18\x01\x12\x1b\n\x0fhop_probability\x18\x02 \x01(\x02\x42\x02\x18\x01\x12\x12\n\x06weight\x18\x03 \x01(\x02\x42\x02\x18\x01\x12\x1f\n\x17maximum_payment_results\x18\x04 \x01(\r\x12&\n\x1eminimum_failure_relax_interval\x18\x05 \x01(\x04\x12?\n\x05model\x18\x06 \x01(\x0e\x32\x30.routerrpc.MissionControlConfig.ProbabilityModel\x12/\n\x07\x61priori\x18\x07 \x01(\x0b\x32\x1c.routerrpc.AprioriParametersH\x00\x12/\n\x07\x62imodal\x18\x08 \x01(\x0b\x32\x1c.routerrpc.BimodalParametersH\x00\",\n\x10ProbabilityModel\x12\x0b\n\x07\x41PRIORI\x10\x00\x12\x0b\n\x07\x42IMODAL\x10\x01\x42\x11\n\x0f\x45stimatorConfig\"P\n\x11\x42imodalParameters\x12\x13\n\x0bnode_weight\x18\x01 \x01(\x01\x12\x12\n\nscale_msat\x18\x02 \x01(\x04\x12\x12\n\ndecay_time\x18\x03 \x01(\x04\"r\n\x11\x41prioriParameters\x12\x19\n\x11half_life_seconds\x18\x01 \x01(\x04\x12\x17\n\x0fhop_probability\x18\x02 \x01(\x01\x12\x0e\n\x06weight\x18\x03 \x01(\x01\x12\x19\n\x11\x63\x61pacity_fraction\x18\x04 \x01(\x01\"O\n\x17QueryProbabilityRequest\x12\x11\n\tfrom_node\x18\x01 \x01(\x0c\x12\x0f\n\x07to_node\x18\x02 \x01(\x0c\x12\x10\n\x08\x61mt_msat\x18\x03 \x01(\x03\"U\n\x18QueryProbabilityResponse\x12\x13\n\x0bprobability\x18\x01 \x01(\x01\x12$\n\x07history\x18\x02 \x01(\x0b\x32\x13.routerrpc.PairData\"\x88\x01\n\x11\x42uildRouteRequest\x12\x10\n\x08\x61mt_msat\x18\x01 \x01(\x03\x12\x18\n\x10\x66inal_cltv_delta\x18\x02 \x01(\x05\x12\x1c\n\x10outgoing_chan_id\x18\x03 \x01(\x04\x42\x02\x30\x01\x12\x13\n\x0bhop_pubkeys\x18\x04 \x03(\x0c\x12\x14\n\x0cpayment_addr\x18\x05 \x01(\x0c\"1\n\x12\x42uildRouteResponse\x12\x1b\n\x05route\x18\x01 \x01(\x0b\x32\x0c.lnrpc.Route\"\x1c\n\x1aSubscribeHtlcEventsRequest\"\xcb\x04\n\tHtlcEvent\x12\x1b\n\x13incoming_channel_id\x18\x01 \x01(\x04\x12\x1b\n\x13outgoing_channel_id\x18\x02 \x01(\x04\x12\x18\n\x10incoming_htlc_id\x18\x03 \x01(\x04\x12\x18\n\x10outgoing_htlc_id\x18\x04 \x01(\x04\x12\x14\n\x0ctimestamp_ns\x18\x05 \x01(\x04\x12\x32\n\nevent_type\x18\x06 \x01(\x0e\x32\x1e.routerrpc.HtlcEvent.EventType\x12\x30\n\rforward_event\x18\x07 \x01(\x0b\x32\x17.routerrpc.ForwardEventH\x00\x12\x39\n\x12\x66orward_fail_event\x18\x08 \x01(\x0b\x32\x1b.routerrpc.ForwardFailEventH\x00\x12.\n\x0csettle_event\x18\t \x01(\x0b\x32\x16.routerrpc.SettleEventH\x00\x12\x33\n\x0flink_fail_event\x18\n \x01(\x0b\x32\x18.routerrpc.LinkFailEventH\x00\x12\x36\n\x10subscribed_event\x18\x0b \x01(\x0b\x32\x1a.routerrpc.SubscribedEventH\x00\x12\x35\n\x10\x66inal_htlc_event\x18\x0c \x01(\x0b\x32\x19.routerrpc.FinalHtlcEventH\x00\"<\n\tEventType\x12\x0b\n\x07UNKNOWN\x10\x00\x12\x08\n\x04SEND\x10\x01\x12\x0b\n\x07RECEIVE\x10\x02\x12\x0b\n\x07\x46ORWARD\x10\x03\x42\x07\n\x05\x65vent\"v\n\x08HtlcInfo\x12\x19\n\x11incoming_timelock\x18\x01 \x01(\r\x12\x19\n\x11outgoing_timelock\x18\x02 \x01(\r\x12\x19\n\x11incoming_amt_msat\x18\x03 \x01(\x04\x12\x19\n\x11outgoing_amt_msat\x18\x04 \x01(\x04\"1\n\x0c\x46orwardEvent\x12!\n\x04info\x18\x01 \x01(\x0b\x32\x13.routerrpc.HtlcInfo\"\x12\n\x10\x46orwardFailEvent\"\x1f\n\x0bSettleEvent\x12\x10\n\x08preimage\x18\x01 \x01(\x0c\"3\n\x0e\x46inalHtlcEvent\x12\x0f\n\x07settled\x18\x01 \x01(\x08\x12\x10\n\x08offchain\x18\x02 \x01(\x08\"\x11\n\x0fSubscribedEvent\"\xae\x01\n\rLinkFailEvent\x12!\n\x04info\x18\x01 \x01(\x0b\x32\x13.routerrpc.HtlcInfo\x12\x30\n\x0cwire_failure\x18\x02 \x01(\x0e\x32\x1a.lnrpc.Failure.FailureCode\x12\x30\n\x0e\x66\x61ilure_detail\x18\x03 \x01(\x0e\x32\x18.routerrpc.FailureDetail\x12\x16\n\x0e\x66\x61ilure_string\x18\x04 \x01(\t\"r\n\rPaymentStatus\x12&\n\x05state\x18\x01 \x01(\x0e\x32\x17.routerrpc.PaymentState\x12\x10\n\x08preimage\x18\x02 \x01(\x0c\x12!\n\x05htlcs\x18\x04 \x03(\x0b\x32\x12.lnrpc.HTLCAttemptJ\x04\x08\x03\x10\x04\".\n\nCircuitKey\x12\x0f\n\x07\x63han_id\x18\x01 \x01(\x04\x12\x0f\n\x07htlc_id\x18\x02 \x01(\x04\"\xb1\x03\n\x1b\x46orwardHtlcInterceptRequest\x12\x33\n\x14incoming_circuit_key\x18\x01 \x01(\x0b\x32\x15.routerrpc.CircuitKey\x12\x1c\n\x14incoming_amount_msat\x18\x05 \x01(\x04\x12\x17\n\x0fincoming_expiry\x18\x06 \x01(\r\x12\x14\n\x0cpayment_hash\x18\x02 \x01(\x0c\x12\"\n\x1aoutgoing_requested_chan_id\x18\x07 \x01(\x04\x12\x1c\n\x14outgoing_amount_msat\x18\x03 \x01(\x04\x12\x17\n\x0foutgoing_expiry\x18\x04 \x01(\r\x12Q\n\x0e\x63ustom_records\x18\x08 \x03(\x0b\x32\x39.routerrpc.ForwardHtlcInterceptRequest.CustomRecordsEntry\x12\x12\n\nonion_blob\x18\t \x01(\x0c\x12\x18\n\x10\x61uto_fail_height\x18\n \x01(\x05\x1a\x34\n\x12\x43ustomRecordsEntry\x12\x0b\n\x03key\x18\x01 \x01(\x04\x12\r\n\x05value\x18\x02 \x01(\x0c:\x02\x38\x01\"\xe5\x01\n\x1c\x46orwardHtlcInterceptResponse\x12\x33\n\x14incoming_circuit_key\x18\x01 \x01(\x0b\x32\x15.routerrpc.CircuitKey\x12\x33\n\x06\x61\x63tion\x18\x02 \x01(\x0e\x32#.routerrpc.ResolveHoldForwardAction\x12\x10\n\x08preimage\x18\x03 \x01(\x0c\x12\x17\n\x0f\x66\x61ilure_message\x18\x04 \x01(\x0c\x12\x30\n\x0c\x66\x61ilure_code\x18\x05 \x01(\x0e\x32\x1a.lnrpc.Failure.FailureCode\"o\n\x17UpdateChanStatusRequest\x12\'\n\nchan_point\x18\x01 \x01(\x0b\x32\x13.lnrpc.ChannelPoint\x12+\n\x06\x61\x63tion\x18\x02 \x01(\x0e\x32\x1b.routerrpc.ChanStatusAction\"\x1a\n\x18UpdateChanStatusResponse*\x81\x04\n\rFailureDetail\x12\x0b\n\x07UNKNOWN\x10\x00\x12\r\n\tNO_DETAIL\x10\x01\x12\x10\n\x0cONION_DECODE\x10\x02\x12\x15\n\x11LINK_NOT_ELIGIBLE\x10\x03\x12\x14\n\x10ON_CHAIN_TIMEOUT\x10\x04\x12\x14\n\x10HTLC_EXCEEDS_MAX\x10\x05\x12\x18\n\x14INSUFFICIENT_BALANCE\x10\x06\x12\x16\n\x12INCOMPLETE_FORWARD\x10\x07\x12\x13\n\x0fHTLC_ADD_FAILED\x10\x08\x12\x15\n\x11\x46ORWARDS_DISABLED\x10\t\x12\x14\n\x10INVOICE_CANCELED\x10\n\x12\x15\n\x11INVOICE_UNDERPAID\x10\x0b\x12\x1b\n\x17INVOICE_EXPIRY_TOO_SOON\x10\x0c\x12\x14\n\x10INVOICE_NOT_OPEN\x10\r\x12\x17\n\x13MPP_INVOICE_TIMEOUT\x10\x0e\x12\x14\n\x10\x41\x44\x44RESS_MISMATCH\x10\x0f\x12\x16\n\x12SET_TOTAL_MISMATCH\x10\x10\x12\x15\n\x11SET_TOTAL_TOO_LOW\x10\x11\x12\x10\n\x0cSET_OVERPAID\x10\x12\x12\x13\n\x0fUNKNOWN_INVOICE\x10\x13\x12\x13\n\x0fINVALID_KEYSEND\x10\x14\x12\x13\n\x0fMPP_IN_PROGRESS\x10\x15\x12\x12\n\x0e\x43IRCULAR_ROUTE\x10\x16*\xae\x01\n\x0cPaymentState\x12\r\n\tIN_FLIGHT\x10\x00\x12\r\n\tSUCCEEDED\x10\x01\x12\x12\n\x0e\x46\x41ILED_TIMEOUT\x10\x02\x12\x13\n\x0f\x46\x41ILED_NO_ROUTE\x10\x03\x12\x10\n\x0c\x46\x41ILED_ERROR\x10\x04\x12$\n FAILED_INCORRECT_PAYMENT_DETAILS\x10\x05\x12\x1f\n\x1b\x46\x41ILED_INSUFFICIENT_BALANCE\x10\x06*<\n\x18ResolveHoldForwardAction\x12\n\n\x06SETTLE\x10\x00\x12\x08\n\x04\x46\x41IL\x10\x01\x12\n\n\x06RESUME\x10\x02*5\n\x10\x43hanStatusAction\x12\n\n\x06\x45NABLE\x10\x00\x12\x0b\n\x07\x44ISABLE\x10\x01\x12\x08\n\x04\x41UTO\x10\x02\x32\xb5\x0c\n\x06Router\x12@\n\rSendPaymentV2\x12\x1d.routerrpc.SendPaymentRequest\x1a\x0e.lnrpc.Payment0\x01\x12\x42\n\x0eTrackPaymentV2\x12\x1e.routerrpc.TrackPaymentRequest\x1a\x0e.lnrpc.Payment0\x01\x12\x42\n\rTrackPayments\x12\x1f.routerrpc.TrackPaymentsRequest\x1a\x0e.lnrpc.Payment0\x01\x12K\n\x10\x45stimateRouteFee\x12\x1a.routerrpc.RouteFeeRequest\x1a\x1b.routerrpc.RouteFeeResponse\x12Q\n\x0bSendToRoute\x12\x1d.routerrpc.SendToRouteRequest\x1a\x1e.routerrpc.SendToRouteResponse\"\x03\x88\x02\x01\x12\x42\n\rSendToRouteV2\x12\x1d.routerrpc.SendToRouteRequest\x1a\x12.lnrpc.HTLCAttempt\x12\x64\n\x13ResetMissionControl\x12%.routerrpc.ResetMissionControlRequest\x1a&.routerrpc.ResetMissionControlResponse\x12\x64\n\x13QueryMissionControl\x12%.routerrpc.QueryMissionControlRequest\x1a&.routerrpc.QueryMissionControlResponse\x12j\n\x15XImportMissionControl\x12\'.routerrpc.XImportMissionControlRequest\x1a(.routerrpc.XImportMissionControlResponse\x12p\n\x17GetMissionControlConfig\x12).routerrpc.GetMissionControlConfigRequest\x1a*.routerrpc.GetMissionControlConfigResponse\x12p\n\x17SetMissionControlConfig\x12).routerrpc.SetMissionControlConfigRequest\x1a*.routerrpc.SetMissionControlConfigResponse\x12[\n\x10QueryProbability\x12\".routerrpc.QueryProbabilityRequest\x1a#.routerrpc.QueryProbabilityResponse\x12I\n\nBuildRoute\x12\x1c.routerrpc.BuildRouteRequest\x1a\x1d.routerrpc.BuildRouteResponse\x12T\n\x13SubscribeHtlcEvents\x12%.routerrpc.SubscribeHtlcEventsRequest\x1a\x14.routerrpc.HtlcEvent0\x01\x12M\n\x0bSendPayment\x12\x1d.routerrpc.SendPaymentRequest\x1a\x18.routerrpc.PaymentStatus\"\x03\x88\x02\x01\x30\x01\x12O\n\x0cTrackPayment\x12\x1e.routerrpc.TrackPaymentRequest\x1a\x18.routerrpc.PaymentStatus\"\x03\x88\x02\x01\x30\x01\x12\x66\n\x0fHtlcInterceptor\x12\'.routerrpc.ForwardHtlcInterceptResponse\x1a&.routerrpc.ForwardHtlcInterceptRequest(\x01\x30\x01\x12[\n\x10UpdateChanStatus\x12\".routerrpc.UpdateChanStatusRequest\x1a#.routerrpc.UpdateChanStatusResponseB1Z/github.com/lightningnetwork/lnd/lnrpc/routerrpcb\x06proto3') 19 | 20 | _globals = globals() 21 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) 22 | _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'router_pb2', _globals) 23 | if not _descriptor._USE_C_DESCRIPTORS: 24 | _globals['DESCRIPTOR']._loaded_options = None 25 | _globals['DESCRIPTOR']._serialized_options = b'Z/github.com/lightningnetwork/lnd/lnrpc/routerrpc' 26 | _globals['_SENDPAYMENTREQUEST_DESTCUSTOMRECORDSENTRY']._loaded_options = None 27 | _globals['_SENDPAYMENTREQUEST_DESTCUSTOMRECORDSENTRY']._serialized_options = b'8\001' 28 | _globals['_SENDPAYMENTREQUEST'].fields_by_name['outgoing_chan_id']._loaded_options = None 29 | _globals['_SENDPAYMENTREQUEST'].fields_by_name['outgoing_chan_id']._serialized_options = b'\030\0010\001' 30 | _globals['_MISSIONCONTROLCONFIG'].fields_by_name['half_life_seconds']._loaded_options = None 31 | _globals['_MISSIONCONTROLCONFIG'].fields_by_name['half_life_seconds']._serialized_options = b'\030\001' 32 | _globals['_MISSIONCONTROLCONFIG'].fields_by_name['hop_probability']._loaded_options = None 33 | _globals['_MISSIONCONTROLCONFIG'].fields_by_name['hop_probability']._serialized_options = b'\030\001' 34 | _globals['_MISSIONCONTROLCONFIG'].fields_by_name['weight']._loaded_options = None 35 | _globals['_MISSIONCONTROLCONFIG'].fields_by_name['weight']._serialized_options = b'\030\001' 36 | _globals['_BUILDROUTEREQUEST'].fields_by_name['outgoing_chan_id']._loaded_options = None 37 | _globals['_BUILDROUTEREQUEST'].fields_by_name['outgoing_chan_id']._serialized_options = b'0\001' 38 | _globals['_FORWARDHTLCINTERCEPTREQUEST_CUSTOMRECORDSENTRY']._loaded_options = None 39 | _globals['_FORWARDHTLCINTERCEPTREQUEST_CUSTOMRECORDSENTRY']._serialized_options = b'8\001' 40 | _globals['_ROUTER'].methods_by_name['SendToRoute']._loaded_options = None 41 | _globals['_ROUTER'].methods_by_name['SendToRoute']._serialized_options = b'\210\002\001' 42 | _globals['_ROUTER'].methods_by_name['SendPayment']._loaded_options = None 43 | _globals['_ROUTER'].methods_by_name['SendPayment']._serialized_options = b'\210\002\001' 44 | _globals['_ROUTER'].methods_by_name['TrackPayment']._loaded_options = None 45 | _globals['_ROUTER'].methods_by_name['TrackPayment']._serialized_options = b'\210\002\001' 46 | _globals['_FAILUREDETAIL']._serialized_start=5075 47 | _globals['_FAILUREDETAIL']._serialized_end=5588 48 | _globals['_PAYMENTSTATE']._serialized_start=5591 49 | _globals['_PAYMENTSTATE']._serialized_end=5765 50 | _globals['_RESOLVEHOLDFORWARDACTION']._serialized_start=5767 51 | _globals['_RESOLVEHOLDFORWARDACTION']._serialized_end=5827 52 | _globals['_CHANSTATUSACTION']._serialized_start=5829 53 | _globals['_CHANSTATUSACTION']._serialized_end=5882 54 | _globals['_SENDPAYMENTREQUEST']._serialized_start=45 55 | _globals['_SENDPAYMENTREQUEST']._serialized_end=740 56 | _globals['_SENDPAYMENTREQUEST_DESTCUSTOMRECORDSENTRY']._serialized_start=684 57 | _globals['_SENDPAYMENTREQUEST_DESTCUSTOMRECORDSENTRY']._serialized_end=740 58 | _globals['_TRACKPAYMENTREQUEST']._serialized_start=742 59 | _globals['_TRACKPAYMENTREQUEST']._serialized_end=814 60 | _globals['_TRACKPAYMENTSREQUEST']._serialized_start=816 61 | _globals['_TRACKPAYMENTSREQUEST']._serialized_end=867 62 | _globals['_ROUTEFEEREQUEST']._serialized_start=869 63 | _globals['_ROUTEFEEREQUEST']._serialized_end=959 64 | _globals['_ROUTEFEERESPONSE']._serialized_start=961 65 | _globals['_ROUTEFEERESPONSE']._serialized_end=1083 66 | _globals['_SENDTOROUTEREQUEST']._serialized_start=1085 67 | _globals['_SENDTOROUTEREQUEST']._serialized_end=1179 68 | _globals['_SENDTOROUTERESPONSE']._serialized_start=1181 69 | _globals['_SENDTOROUTERESPONSE']._serialized_end=1253 70 | _globals['_RESETMISSIONCONTROLREQUEST']._serialized_start=1255 71 | _globals['_RESETMISSIONCONTROLREQUEST']._serialized_end=1283 72 | _globals['_RESETMISSIONCONTROLRESPONSE']._serialized_start=1285 73 | _globals['_RESETMISSIONCONTROLRESPONSE']._serialized_end=1314 74 | _globals['_QUERYMISSIONCONTROLREQUEST']._serialized_start=1316 75 | _globals['_QUERYMISSIONCONTROLREQUEST']._serialized_end=1344 76 | _globals['_QUERYMISSIONCONTROLRESPONSE']._serialized_start=1346 77 | _globals['_QUERYMISSIONCONTROLRESPONSE']._serialized_end=1420 78 | _globals['_XIMPORTMISSIONCONTROLREQUEST']._serialized_start=1422 79 | _globals['_XIMPORTMISSIONCONTROLREQUEST']._serialized_end=1506 80 | _globals['_XIMPORTMISSIONCONTROLRESPONSE']._serialized_start=1508 81 | _globals['_XIMPORTMISSIONCONTROLRESPONSE']._serialized_end=1539 82 | _globals['_PAIRHISTORY']._serialized_start=1541 83 | _globals['_PAIRHISTORY']._serialized_end=1652 84 | _globals['_PAIRDATA']._serialized_start=1655 85 | _globals['_PAIRDATA']._serialized_end=1808 86 | _globals['_GETMISSIONCONTROLCONFIGREQUEST']._serialized_start=1810 87 | _globals['_GETMISSIONCONTROLCONFIGREQUEST']._serialized_end=1842 88 | _globals['_GETMISSIONCONTROLCONFIGRESPONSE']._serialized_start=1844 89 | _globals['_GETMISSIONCONTROLCONFIGRESPONSE']._serialized_end=1926 90 | _globals['_SETMISSIONCONTROLCONFIGREQUEST']._serialized_start=1928 91 | _globals['_SETMISSIONCONTROLCONFIGREQUEST']._serialized_end=2009 92 | _globals['_SETMISSIONCONTROLCONFIGRESPONSE']._serialized_start=2011 93 | _globals['_SETMISSIONCONTROLCONFIGRESPONSE']._serialized_end=2044 94 | _globals['_MISSIONCONTROLCONFIG']._serialized_start=2047 95 | _globals['_MISSIONCONTROLCONFIG']._serialized_end=2450 96 | _globals['_MISSIONCONTROLCONFIG_PROBABILITYMODEL']._serialized_start=2387 97 | _globals['_MISSIONCONTROLCONFIG_PROBABILITYMODEL']._serialized_end=2431 98 | _globals['_BIMODALPARAMETERS']._serialized_start=2452 99 | _globals['_BIMODALPARAMETERS']._serialized_end=2532 100 | _globals['_APRIORIPARAMETERS']._serialized_start=2534 101 | _globals['_APRIORIPARAMETERS']._serialized_end=2648 102 | _globals['_QUERYPROBABILITYREQUEST']._serialized_start=2650 103 | _globals['_QUERYPROBABILITYREQUEST']._serialized_end=2729 104 | _globals['_QUERYPROBABILITYRESPONSE']._serialized_start=2731 105 | _globals['_QUERYPROBABILITYRESPONSE']._serialized_end=2816 106 | _globals['_BUILDROUTEREQUEST']._serialized_start=2819 107 | _globals['_BUILDROUTEREQUEST']._serialized_end=2955 108 | _globals['_BUILDROUTERESPONSE']._serialized_start=2957 109 | _globals['_BUILDROUTERESPONSE']._serialized_end=3006 110 | _globals['_SUBSCRIBEHTLCEVENTSREQUEST']._serialized_start=3008 111 | _globals['_SUBSCRIBEHTLCEVENTSREQUEST']._serialized_end=3036 112 | _globals['_HTLCEVENT']._serialized_start=3039 113 | _globals['_HTLCEVENT']._serialized_end=3626 114 | _globals['_HTLCEVENT_EVENTTYPE']._serialized_start=3557 115 | _globals['_HTLCEVENT_EVENTTYPE']._serialized_end=3617 116 | _globals['_HTLCINFO']._serialized_start=3628 117 | _globals['_HTLCINFO']._serialized_end=3746 118 | _globals['_FORWARDEVENT']._serialized_start=3748 119 | _globals['_FORWARDEVENT']._serialized_end=3797 120 | _globals['_FORWARDFAILEVENT']._serialized_start=3799 121 | _globals['_FORWARDFAILEVENT']._serialized_end=3817 122 | _globals['_SETTLEEVENT']._serialized_start=3819 123 | _globals['_SETTLEEVENT']._serialized_end=3850 124 | _globals['_FINALHTLCEVENT']._serialized_start=3852 125 | _globals['_FINALHTLCEVENT']._serialized_end=3903 126 | _globals['_SUBSCRIBEDEVENT']._serialized_start=3905 127 | _globals['_SUBSCRIBEDEVENT']._serialized_end=3922 128 | _globals['_LINKFAILEVENT']._serialized_start=3925 129 | _globals['_LINKFAILEVENT']._serialized_end=4099 130 | _globals['_PAYMENTSTATUS']._serialized_start=4101 131 | _globals['_PAYMENTSTATUS']._serialized_end=4215 132 | _globals['_CIRCUITKEY']._serialized_start=4217 133 | _globals['_CIRCUITKEY']._serialized_end=4263 134 | _globals['_FORWARDHTLCINTERCEPTREQUEST']._serialized_start=4266 135 | _globals['_FORWARDHTLCINTERCEPTREQUEST']._serialized_end=4699 136 | _globals['_FORWARDHTLCINTERCEPTREQUEST_CUSTOMRECORDSENTRY']._serialized_start=4647 137 | _globals['_FORWARDHTLCINTERCEPTREQUEST_CUSTOMRECORDSENTRY']._serialized_end=4699 138 | _globals['_FORWARDHTLCINTERCEPTRESPONSE']._serialized_start=4702 139 | _globals['_FORWARDHTLCINTERCEPTRESPONSE']._serialized_end=4931 140 | _globals['_UPDATECHANSTATUSREQUEST']._serialized_start=4933 141 | _globals['_UPDATECHANSTATUSREQUEST']._serialized_end=5044 142 | _globals['_UPDATECHANSTATUSRESPONSE']._serialized_start=5046 143 | _globals['_UPDATECHANSTATUSRESPONSE']._serialized_end=5072 144 | _globals['_ROUTER']._serialized_start=5885 145 | _globals['_ROUTER']._serialized_end=7474 146 | # @@protoc_insertion_point(module_scope) 147 | -------------------------------------------------------------------------------- /grpc_generated/router_pb2_grpc.py: -------------------------------------------------------------------------------- 1 | # Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! 2 | """Client and server classes corresponding to protobuf-defined services.""" 3 | import grpc 4 | import warnings 5 | 6 | from grpc_generated import lightning_pb2 as lightning__pb2 7 | from grpc_generated import router_pb2 as router__pb2 8 | 9 | GRPC_GENERATED_VERSION = '1.63.0' 10 | GRPC_VERSION = grpc.__version__ 11 | EXPECTED_ERROR_RELEASE = '1.65.0' 12 | SCHEDULED_RELEASE_DATE = 'June 25, 2024' 13 | _version_not_supported = False 14 | 15 | try: 16 | from grpc._utilities import first_version_is_lower 17 | _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) 18 | except ImportError: 19 | _version_not_supported = True 20 | 21 | if _version_not_supported: 22 | warnings.warn( 23 | f'The grpc package installed is at version {GRPC_VERSION},' 24 | + f' but the generated code in router_pb2_grpc.py depends on' 25 | + f' grpcio>={GRPC_GENERATED_VERSION}.' 26 | + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' 27 | + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' 28 | + f' This warning will become an error in {EXPECTED_ERROR_RELEASE},' 29 | + f' scheduled for release on {SCHEDULED_RELEASE_DATE}.', 30 | RuntimeWarning 31 | ) 32 | 33 | 34 | class RouterStub(object): 35 | """ 36 | Comments in this file will be directly parsed into the API 37 | Documentation as descriptions of the associated method, message, or field. 38 | These descriptions should go right above the definition of the object, and 39 | can be in either block or // comment format. 40 | 41 | An RPC method can be matched to an lncli command by placing a line in the 42 | beginning of the description in exactly the following format: 43 | lncli: `methodname` 44 | 45 | Failure to specify the exact name of the command will cause documentation 46 | generation to fail. 47 | 48 | More information on how exactly the gRPC documentation is generated from 49 | this proto file can be found here: 50 | https://github.com/lightninglabs/lightning-api 51 | 52 | Router is a service that offers advanced interaction with the router 53 | subsystem of the daemon. 54 | """ 55 | 56 | def __init__(self, channel): 57 | """Constructor. 58 | 59 | Args: 60 | channel: A grpc.Channel. 61 | """ 62 | self.SendPaymentV2 = channel.unary_stream( 63 | '/routerrpc.Router/SendPaymentV2', 64 | request_serializer=router__pb2.SendPaymentRequest.SerializeToString, 65 | response_deserializer=lightning__pb2.Payment.FromString, 66 | _registered_method=True) 67 | self.TrackPaymentV2 = channel.unary_stream( 68 | '/routerrpc.Router/TrackPaymentV2', 69 | request_serializer=router__pb2.TrackPaymentRequest.SerializeToString, 70 | response_deserializer=lightning__pb2.Payment.FromString, 71 | _registered_method=True) 72 | self.TrackPayments = channel.unary_stream( 73 | '/routerrpc.Router/TrackPayments', 74 | request_serializer=router__pb2.TrackPaymentsRequest.SerializeToString, 75 | response_deserializer=lightning__pb2.Payment.FromString, 76 | _registered_method=True) 77 | self.EstimateRouteFee = channel.unary_unary( 78 | '/routerrpc.Router/EstimateRouteFee', 79 | request_serializer=router__pb2.RouteFeeRequest.SerializeToString, 80 | response_deserializer=router__pb2.RouteFeeResponse.FromString, 81 | _registered_method=True) 82 | self.SendToRoute = channel.unary_unary( 83 | '/routerrpc.Router/SendToRoute', 84 | request_serializer=router__pb2.SendToRouteRequest.SerializeToString, 85 | response_deserializer=router__pb2.SendToRouteResponse.FromString, 86 | _registered_method=True) 87 | self.SendToRouteV2 = channel.unary_unary( 88 | '/routerrpc.Router/SendToRouteV2', 89 | request_serializer=router__pb2.SendToRouteRequest.SerializeToString, 90 | response_deserializer=lightning__pb2.HTLCAttempt.FromString, 91 | _registered_method=True) 92 | self.ResetMissionControl = channel.unary_unary( 93 | '/routerrpc.Router/ResetMissionControl', 94 | request_serializer=router__pb2.ResetMissionControlRequest.SerializeToString, 95 | response_deserializer=router__pb2.ResetMissionControlResponse.FromString, 96 | _registered_method=True) 97 | self.QueryMissionControl = channel.unary_unary( 98 | '/routerrpc.Router/QueryMissionControl', 99 | request_serializer=router__pb2.QueryMissionControlRequest.SerializeToString, 100 | response_deserializer=router__pb2.QueryMissionControlResponse.FromString, 101 | _registered_method=True) 102 | self.XImportMissionControl = channel.unary_unary( 103 | '/routerrpc.Router/XImportMissionControl', 104 | request_serializer=router__pb2.XImportMissionControlRequest.SerializeToString, 105 | response_deserializer=router__pb2.XImportMissionControlResponse.FromString, 106 | _registered_method=True) 107 | self.GetMissionControlConfig = channel.unary_unary( 108 | '/routerrpc.Router/GetMissionControlConfig', 109 | request_serializer=router__pb2.GetMissionControlConfigRequest.SerializeToString, 110 | response_deserializer=router__pb2.GetMissionControlConfigResponse.FromString, 111 | _registered_method=True) 112 | self.SetMissionControlConfig = channel.unary_unary( 113 | '/routerrpc.Router/SetMissionControlConfig', 114 | request_serializer=router__pb2.SetMissionControlConfigRequest.SerializeToString, 115 | response_deserializer=router__pb2.SetMissionControlConfigResponse.FromString, 116 | _registered_method=True) 117 | self.QueryProbability = channel.unary_unary( 118 | '/routerrpc.Router/QueryProbability', 119 | request_serializer=router__pb2.QueryProbabilityRequest.SerializeToString, 120 | response_deserializer=router__pb2.QueryProbabilityResponse.FromString, 121 | _registered_method=True) 122 | self.BuildRoute = channel.unary_unary( 123 | '/routerrpc.Router/BuildRoute', 124 | request_serializer=router__pb2.BuildRouteRequest.SerializeToString, 125 | response_deserializer=router__pb2.BuildRouteResponse.FromString, 126 | _registered_method=True) 127 | self.SubscribeHtlcEvents = channel.unary_stream( 128 | '/routerrpc.Router/SubscribeHtlcEvents', 129 | request_serializer=router__pb2.SubscribeHtlcEventsRequest.SerializeToString, 130 | response_deserializer=router__pb2.HtlcEvent.FromString, 131 | _registered_method=True) 132 | self.SendPayment = channel.unary_stream( 133 | '/routerrpc.Router/SendPayment', 134 | request_serializer=router__pb2.SendPaymentRequest.SerializeToString, 135 | response_deserializer=router__pb2.PaymentStatus.FromString, 136 | _registered_method=True) 137 | self.TrackPayment = channel.unary_stream( 138 | '/routerrpc.Router/TrackPayment', 139 | request_serializer=router__pb2.TrackPaymentRequest.SerializeToString, 140 | response_deserializer=router__pb2.PaymentStatus.FromString, 141 | _registered_method=True) 142 | self.HtlcInterceptor = channel.stream_stream( 143 | '/routerrpc.Router/HtlcInterceptor', 144 | request_serializer=router__pb2.ForwardHtlcInterceptResponse.SerializeToString, 145 | response_deserializer=router__pb2.ForwardHtlcInterceptRequest.FromString, 146 | _registered_method=True) 147 | self.UpdateChanStatus = channel.unary_unary( 148 | '/routerrpc.Router/UpdateChanStatus', 149 | request_serializer=router__pb2.UpdateChanStatusRequest.SerializeToString, 150 | response_deserializer=router__pb2.UpdateChanStatusResponse.FromString, 151 | _registered_method=True) 152 | 153 | 154 | class RouterServicer(object): 155 | """ 156 | Comments in this file will be directly parsed into the API 157 | Documentation as descriptions of the associated method, message, or field. 158 | These descriptions should go right above the definition of the object, and 159 | can be in either block or // comment format. 160 | 161 | An RPC method can be matched to an lncli command by placing a line in the 162 | beginning of the description in exactly the following format: 163 | lncli: `methodname` 164 | 165 | Failure to specify the exact name of the command will cause documentation 166 | generation to fail. 167 | 168 | More information on how exactly the gRPC documentation is generated from 169 | this proto file can be found here: 170 | https://github.com/lightninglabs/lightning-api 171 | 172 | Router is a service that offers advanced interaction with the router 173 | subsystem of the daemon. 174 | """ 175 | 176 | def SendPaymentV2(self, request, context): 177 | """ 178 | SendPaymentV2 attempts to route a payment described by the passed 179 | PaymentRequest to the final destination. The call returns a stream of 180 | payment updates. When using this RPC, make sure to set a fee limit, as the 181 | default routing fee limit is 0 sats. Without a non-zero fee limit only 182 | routes without fees will be attempted which often fails with 183 | FAILURE_REASON_NO_ROUTE. 184 | """ 185 | context.set_code(grpc.StatusCode.UNIMPLEMENTED) 186 | context.set_details('Method not implemented!') 187 | raise NotImplementedError('Method not implemented!') 188 | 189 | def TrackPaymentV2(self, request, context): 190 | """lncli: `trackpayment` 191 | TrackPaymentV2 returns an update stream for the payment identified by the 192 | payment hash. 193 | """ 194 | context.set_code(grpc.StatusCode.UNIMPLEMENTED) 195 | context.set_details('Method not implemented!') 196 | raise NotImplementedError('Method not implemented!') 197 | 198 | def TrackPayments(self, request, context): 199 | """ 200 | TrackPayments returns an update stream for every payment that is not in a 201 | terminal state. Note that if payments are in-flight while starting a new 202 | subscription, the start of the payment stream could produce out-of-order 203 | and/or duplicate events. In order to get updates for every in-flight 204 | payment attempt make sure to subscribe to this method before initiating any 205 | payments. 206 | """ 207 | context.set_code(grpc.StatusCode.UNIMPLEMENTED) 208 | context.set_details('Method not implemented!') 209 | raise NotImplementedError('Method not implemented!') 210 | 211 | def EstimateRouteFee(self, request, context): 212 | """ 213 | EstimateRouteFee allows callers to obtain a lower bound w.r.t how much it 214 | may cost to send an HTLC to the target end destination. 215 | """ 216 | context.set_code(grpc.StatusCode.UNIMPLEMENTED) 217 | context.set_details('Method not implemented!') 218 | raise NotImplementedError('Method not implemented!') 219 | 220 | def SendToRoute(self, request, context): 221 | """ 222 | Deprecated, use SendToRouteV2. SendToRoute attempts to make a payment via 223 | the specified route. This method differs from SendPayment in that it 224 | allows users to specify a full route manually. This can be used for 225 | things like rebalancing, and atomic swaps. It differs from the newer 226 | SendToRouteV2 in that it doesn't return the full HTLC information. 227 | """ 228 | context.set_code(grpc.StatusCode.UNIMPLEMENTED) 229 | context.set_details('Method not implemented!') 230 | raise NotImplementedError('Method not implemented!') 231 | 232 | def SendToRouteV2(self, request, context): 233 | """ 234 | SendToRouteV2 attempts to make a payment via the specified route. This 235 | method differs from SendPayment in that it allows users to specify a full 236 | route manually. This can be used for things like rebalancing, and atomic 237 | swaps. 238 | """ 239 | context.set_code(grpc.StatusCode.UNIMPLEMENTED) 240 | context.set_details('Method not implemented!') 241 | raise NotImplementedError('Method not implemented!') 242 | 243 | def ResetMissionControl(self, request, context): 244 | """lncli: `resetmc` 245 | ResetMissionControl clears all mission control state and starts with a clean 246 | slate. 247 | """ 248 | context.set_code(grpc.StatusCode.UNIMPLEMENTED) 249 | context.set_details('Method not implemented!') 250 | raise NotImplementedError('Method not implemented!') 251 | 252 | def QueryMissionControl(self, request, context): 253 | """lncli: `querymc` 254 | QueryMissionControl exposes the internal mission control state to callers. 255 | It is a development feature. 256 | """ 257 | context.set_code(grpc.StatusCode.UNIMPLEMENTED) 258 | context.set_details('Method not implemented!') 259 | raise NotImplementedError('Method not implemented!') 260 | 261 | def XImportMissionControl(self, request, context): 262 | """lncli: `importmc` 263 | XImportMissionControl is an experimental API that imports the state provided 264 | to the internal mission control's state, using all results which are more 265 | recent than our existing values. These values will only be imported 266 | in-memory, and will not be persisted across restarts. 267 | """ 268 | context.set_code(grpc.StatusCode.UNIMPLEMENTED) 269 | context.set_details('Method not implemented!') 270 | raise NotImplementedError('Method not implemented!') 271 | 272 | def GetMissionControlConfig(self, request, context): 273 | """lncli: `getmccfg` 274 | GetMissionControlConfig returns mission control's current config. 275 | """ 276 | context.set_code(grpc.StatusCode.UNIMPLEMENTED) 277 | context.set_details('Method not implemented!') 278 | raise NotImplementedError('Method not implemented!') 279 | 280 | def SetMissionControlConfig(self, request, context): 281 | """lncli: `setmccfg` 282 | SetMissionControlConfig will set mission control's config, if the config 283 | provided is valid. 284 | """ 285 | context.set_code(grpc.StatusCode.UNIMPLEMENTED) 286 | context.set_details('Method not implemented!') 287 | raise NotImplementedError('Method not implemented!') 288 | 289 | def QueryProbability(self, request, context): 290 | """lncli: `queryprob` 291 | Deprecated. QueryProbability returns the current success probability 292 | estimate for a given node pair and amount. The call returns a zero success 293 | probability if no channel is available or if the amount violates min/max 294 | HTLC constraints. 295 | """ 296 | context.set_code(grpc.StatusCode.UNIMPLEMENTED) 297 | context.set_details('Method not implemented!') 298 | raise NotImplementedError('Method not implemented!') 299 | 300 | def BuildRoute(self, request, context): 301 | """lncli: `buildroute` 302 | BuildRoute builds a fully specified route based on a list of hop public 303 | keys. It retrieves the relevant channel policies from the graph in order to 304 | calculate the correct fees and time locks. 305 | Note that LND will use its default final_cltv_delta if no value is supplied. 306 | Make sure to add the correct final_cltv_delta depending on the invoice 307 | restriction. Moreover the caller has to make sure to provide the 308 | payment_addr if the route is paying an invoice which signaled it. 309 | """ 310 | context.set_code(grpc.StatusCode.UNIMPLEMENTED) 311 | context.set_details('Method not implemented!') 312 | raise NotImplementedError('Method not implemented!') 313 | 314 | def SubscribeHtlcEvents(self, request, context): 315 | """ 316 | SubscribeHtlcEvents creates a uni-directional stream from the server to 317 | the client which delivers a stream of htlc events. 318 | """ 319 | context.set_code(grpc.StatusCode.UNIMPLEMENTED) 320 | context.set_details('Method not implemented!') 321 | raise NotImplementedError('Method not implemented!') 322 | 323 | def SendPayment(self, request, context): 324 | """ 325 | Deprecated, use SendPaymentV2. SendPayment attempts to route a payment 326 | described by the passed PaymentRequest to the final destination. The call 327 | returns a stream of payment status updates. 328 | """ 329 | context.set_code(grpc.StatusCode.UNIMPLEMENTED) 330 | context.set_details('Method not implemented!') 331 | raise NotImplementedError('Method not implemented!') 332 | 333 | def TrackPayment(self, request, context): 334 | """ 335 | Deprecated, use TrackPaymentV2. TrackPayment returns an update stream for 336 | the payment identified by the payment hash. 337 | """ 338 | context.set_code(grpc.StatusCode.UNIMPLEMENTED) 339 | context.set_details('Method not implemented!') 340 | raise NotImplementedError('Method not implemented!') 341 | 342 | def HtlcInterceptor(self, request_iterator, context): 343 | """* 344 | HtlcInterceptor dispatches a bi-directional streaming RPC in which 345 | Forwarded HTLC requests are sent to the client and the client responds with 346 | a boolean that tells LND if this htlc should be intercepted. 347 | In case of interception, the htlc can be either settled, cancelled or 348 | resumed later by using the ResolveHoldForward endpoint. 349 | """ 350 | context.set_code(grpc.StatusCode.UNIMPLEMENTED) 351 | context.set_details('Method not implemented!') 352 | raise NotImplementedError('Method not implemented!') 353 | 354 | def UpdateChanStatus(self, request, context): 355 | """lncli: `updatechanstatus` 356 | UpdateChanStatus attempts to manually set the state of a channel 357 | (enabled, disabled, or auto). A manual "disable" request will cause the 358 | channel to stay disabled until a subsequent manual request of either 359 | "enable" or "auto". 360 | """ 361 | context.set_code(grpc.StatusCode.UNIMPLEMENTED) 362 | context.set_details('Method not implemented!') 363 | raise NotImplementedError('Method not implemented!') 364 | 365 | 366 | def add_RouterServicer_to_server(servicer, server): 367 | rpc_method_handlers = { 368 | 'SendPaymentV2': grpc.unary_stream_rpc_method_handler( 369 | servicer.SendPaymentV2, 370 | request_deserializer=router__pb2.SendPaymentRequest.FromString, 371 | response_serializer=lightning__pb2.Payment.SerializeToString, 372 | ), 373 | 'TrackPaymentV2': grpc.unary_stream_rpc_method_handler( 374 | servicer.TrackPaymentV2, 375 | request_deserializer=router__pb2.TrackPaymentRequest.FromString, 376 | response_serializer=lightning__pb2.Payment.SerializeToString, 377 | ), 378 | 'TrackPayments': grpc.unary_stream_rpc_method_handler( 379 | servicer.TrackPayments, 380 | request_deserializer=router__pb2.TrackPaymentsRequest.FromString, 381 | response_serializer=lightning__pb2.Payment.SerializeToString, 382 | ), 383 | 'EstimateRouteFee': grpc.unary_unary_rpc_method_handler( 384 | servicer.EstimateRouteFee, 385 | request_deserializer=router__pb2.RouteFeeRequest.FromString, 386 | response_serializer=router__pb2.RouteFeeResponse.SerializeToString, 387 | ), 388 | 'SendToRoute': grpc.unary_unary_rpc_method_handler( 389 | servicer.SendToRoute, 390 | request_deserializer=router__pb2.SendToRouteRequest.FromString, 391 | response_serializer=router__pb2.SendToRouteResponse.SerializeToString, 392 | ), 393 | 'SendToRouteV2': grpc.unary_unary_rpc_method_handler( 394 | servicer.SendToRouteV2, 395 | request_deserializer=router__pb2.SendToRouteRequest.FromString, 396 | response_serializer=lightning__pb2.HTLCAttempt.SerializeToString, 397 | ), 398 | 'ResetMissionControl': grpc.unary_unary_rpc_method_handler( 399 | servicer.ResetMissionControl, 400 | request_deserializer=router__pb2.ResetMissionControlRequest.FromString, 401 | response_serializer=router__pb2.ResetMissionControlResponse.SerializeToString, 402 | ), 403 | 'QueryMissionControl': grpc.unary_unary_rpc_method_handler( 404 | servicer.QueryMissionControl, 405 | request_deserializer=router__pb2.QueryMissionControlRequest.FromString, 406 | response_serializer=router__pb2.QueryMissionControlResponse.SerializeToString, 407 | ), 408 | 'XImportMissionControl': grpc.unary_unary_rpc_method_handler( 409 | servicer.XImportMissionControl, 410 | request_deserializer=router__pb2.XImportMissionControlRequest.FromString, 411 | response_serializer=router__pb2.XImportMissionControlResponse.SerializeToString, 412 | ), 413 | 'GetMissionControlConfig': grpc.unary_unary_rpc_method_handler( 414 | servicer.GetMissionControlConfig, 415 | request_deserializer=router__pb2.GetMissionControlConfigRequest.FromString, 416 | response_serializer=router__pb2.GetMissionControlConfigResponse.SerializeToString, 417 | ), 418 | 'SetMissionControlConfig': grpc.unary_unary_rpc_method_handler( 419 | servicer.SetMissionControlConfig, 420 | request_deserializer=router__pb2.SetMissionControlConfigRequest.FromString, 421 | response_serializer=router__pb2.SetMissionControlConfigResponse.SerializeToString, 422 | ), 423 | 'QueryProbability': grpc.unary_unary_rpc_method_handler( 424 | servicer.QueryProbability, 425 | request_deserializer=router__pb2.QueryProbabilityRequest.FromString, 426 | response_serializer=router__pb2.QueryProbabilityResponse.SerializeToString, 427 | ), 428 | 'BuildRoute': grpc.unary_unary_rpc_method_handler( 429 | servicer.BuildRoute, 430 | request_deserializer=router__pb2.BuildRouteRequest.FromString, 431 | response_serializer=router__pb2.BuildRouteResponse.SerializeToString, 432 | ), 433 | 'SubscribeHtlcEvents': grpc.unary_stream_rpc_method_handler( 434 | servicer.SubscribeHtlcEvents, 435 | request_deserializer=router__pb2.SubscribeHtlcEventsRequest.FromString, 436 | response_serializer=router__pb2.HtlcEvent.SerializeToString, 437 | ), 438 | 'SendPayment': grpc.unary_stream_rpc_method_handler( 439 | servicer.SendPayment, 440 | request_deserializer=router__pb2.SendPaymentRequest.FromString, 441 | response_serializer=router__pb2.PaymentStatus.SerializeToString, 442 | ), 443 | 'TrackPayment': grpc.unary_stream_rpc_method_handler( 444 | servicer.TrackPayment, 445 | request_deserializer=router__pb2.TrackPaymentRequest.FromString, 446 | response_serializer=router__pb2.PaymentStatus.SerializeToString, 447 | ), 448 | 'HtlcInterceptor': grpc.stream_stream_rpc_method_handler( 449 | servicer.HtlcInterceptor, 450 | request_deserializer=router__pb2.ForwardHtlcInterceptResponse.FromString, 451 | response_serializer=router__pb2.ForwardHtlcInterceptRequest.SerializeToString, 452 | ), 453 | 'UpdateChanStatus': grpc.unary_unary_rpc_method_handler( 454 | servicer.UpdateChanStatus, 455 | request_deserializer=router__pb2.UpdateChanStatusRequest.FromString, 456 | response_serializer=router__pb2.UpdateChanStatusResponse.SerializeToString, 457 | ), 458 | } 459 | generic_handler = grpc.method_handlers_generic_handler( 460 | 'routerrpc.Router', rpc_method_handlers) 461 | server.add_generic_rpc_handlers((generic_handler,)) 462 | 463 | 464 | # This class is part of an EXPERIMENTAL API. 465 | class Router(object): 466 | """ 467 | Comments in this file will be directly parsed into the API 468 | Documentation as descriptions of the associated method, message, or field. 469 | These descriptions should go right above the definition of the object, and 470 | can be in either block or // comment format. 471 | 472 | An RPC method can be matched to an lncli command by placing a line in the 473 | beginning of the description in exactly the following format: 474 | lncli: `methodname` 475 | 476 | Failure to specify the exact name of the command will cause documentation 477 | generation to fail. 478 | 479 | More information on how exactly the gRPC documentation is generated from 480 | this proto file can be found here: 481 | https://github.com/lightninglabs/lightning-api 482 | 483 | Router is a service that offers advanced interaction with the router 484 | subsystem of the daemon. 485 | """ 486 | 487 | @staticmethod 488 | def SendPaymentV2(request, 489 | target, 490 | options=(), 491 | channel_credentials=None, 492 | call_credentials=None, 493 | insecure=False, 494 | compression=None, 495 | wait_for_ready=None, 496 | timeout=None, 497 | metadata=None): 498 | return grpc.experimental.unary_stream( 499 | request, 500 | target, 501 | '/routerrpc.Router/SendPaymentV2', 502 | router__pb2.SendPaymentRequest.SerializeToString, 503 | lightning__pb2.Payment.FromString, 504 | options, 505 | channel_credentials, 506 | insecure, 507 | call_credentials, 508 | compression, 509 | wait_for_ready, 510 | timeout, 511 | metadata, 512 | _registered_method=True) 513 | 514 | @staticmethod 515 | def TrackPaymentV2(request, 516 | target, 517 | options=(), 518 | channel_credentials=None, 519 | call_credentials=None, 520 | insecure=False, 521 | compression=None, 522 | wait_for_ready=None, 523 | timeout=None, 524 | metadata=None): 525 | return grpc.experimental.unary_stream( 526 | request, 527 | target, 528 | '/routerrpc.Router/TrackPaymentV2', 529 | router__pb2.TrackPaymentRequest.SerializeToString, 530 | lightning__pb2.Payment.FromString, 531 | options, 532 | channel_credentials, 533 | insecure, 534 | call_credentials, 535 | compression, 536 | wait_for_ready, 537 | timeout, 538 | metadata, 539 | _registered_method=True) 540 | 541 | @staticmethod 542 | def TrackPayments(request, 543 | target, 544 | options=(), 545 | channel_credentials=None, 546 | call_credentials=None, 547 | insecure=False, 548 | compression=None, 549 | wait_for_ready=None, 550 | timeout=None, 551 | metadata=None): 552 | return grpc.experimental.unary_stream( 553 | request, 554 | target, 555 | '/routerrpc.Router/TrackPayments', 556 | router__pb2.TrackPaymentsRequest.SerializeToString, 557 | lightning__pb2.Payment.FromString, 558 | options, 559 | channel_credentials, 560 | insecure, 561 | call_credentials, 562 | compression, 563 | wait_for_ready, 564 | timeout, 565 | metadata, 566 | _registered_method=True) 567 | 568 | @staticmethod 569 | def EstimateRouteFee(request, 570 | target, 571 | options=(), 572 | channel_credentials=None, 573 | call_credentials=None, 574 | insecure=False, 575 | compression=None, 576 | wait_for_ready=None, 577 | timeout=None, 578 | metadata=None): 579 | return grpc.experimental.unary_unary( 580 | request, 581 | target, 582 | '/routerrpc.Router/EstimateRouteFee', 583 | router__pb2.RouteFeeRequest.SerializeToString, 584 | router__pb2.RouteFeeResponse.FromString, 585 | options, 586 | channel_credentials, 587 | insecure, 588 | call_credentials, 589 | compression, 590 | wait_for_ready, 591 | timeout, 592 | metadata, 593 | _registered_method=True) 594 | 595 | @staticmethod 596 | def SendToRoute(request, 597 | target, 598 | options=(), 599 | channel_credentials=None, 600 | call_credentials=None, 601 | insecure=False, 602 | compression=None, 603 | wait_for_ready=None, 604 | timeout=None, 605 | metadata=None): 606 | return grpc.experimental.unary_unary( 607 | request, 608 | target, 609 | '/routerrpc.Router/SendToRoute', 610 | router__pb2.SendToRouteRequest.SerializeToString, 611 | router__pb2.SendToRouteResponse.FromString, 612 | options, 613 | channel_credentials, 614 | insecure, 615 | call_credentials, 616 | compression, 617 | wait_for_ready, 618 | timeout, 619 | metadata, 620 | _registered_method=True) 621 | 622 | @staticmethod 623 | def SendToRouteV2(request, 624 | target, 625 | options=(), 626 | channel_credentials=None, 627 | call_credentials=None, 628 | insecure=False, 629 | compression=None, 630 | wait_for_ready=None, 631 | timeout=None, 632 | metadata=None): 633 | return grpc.experimental.unary_unary( 634 | request, 635 | target, 636 | '/routerrpc.Router/SendToRouteV2', 637 | router__pb2.SendToRouteRequest.SerializeToString, 638 | lightning__pb2.HTLCAttempt.FromString, 639 | options, 640 | channel_credentials, 641 | insecure, 642 | call_credentials, 643 | compression, 644 | wait_for_ready, 645 | timeout, 646 | metadata, 647 | _registered_method=True) 648 | 649 | @staticmethod 650 | def ResetMissionControl(request, 651 | target, 652 | options=(), 653 | channel_credentials=None, 654 | call_credentials=None, 655 | insecure=False, 656 | compression=None, 657 | wait_for_ready=None, 658 | timeout=None, 659 | metadata=None): 660 | return grpc.experimental.unary_unary( 661 | request, 662 | target, 663 | '/routerrpc.Router/ResetMissionControl', 664 | router__pb2.ResetMissionControlRequest.SerializeToString, 665 | router__pb2.ResetMissionControlResponse.FromString, 666 | options, 667 | channel_credentials, 668 | insecure, 669 | call_credentials, 670 | compression, 671 | wait_for_ready, 672 | timeout, 673 | metadata, 674 | _registered_method=True) 675 | 676 | @staticmethod 677 | def QueryMissionControl(request, 678 | target, 679 | options=(), 680 | channel_credentials=None, 681 | call_credentials=None, 682 | insecure=False, 683 | compression=None, 684 | wait_for_ready=None, 685 | timeout=None, 686 | metadata=None): 687 | return grpc.experimental.unary_unary( 688 | request, 689 | target, 690 | '/routerrpc.Router/QueryMissionControl', 691 | router__pb2.QueryMissionControlRequest.SerializeToString, 692 | router__pb2.QueryMissionControlResponse.FromString, 693 | options, 694 | channel_credentials, 695 | insecure, 696 | call_credentials, 697 | compression, 698 | wait_for_ready, 699 | timeout, 700 | metadata, 701 | _registered_method=True) 702 | 703 | @staticmethod 704 | def XImportMissionControl(request, 705 | target, 706 | options=(), 707 | channel_credentials=None, 708 | call_credentials=None, 709 | insecure=False, 710 | compression=None, 711 | wait_for_ready=None, 712 | timeout=None, 713 | metadata=None): 714 | return grpc.experimental.unary_unary( 715 | request, 716 | target, 717 | '/routerrpc.Router/XImportMissionControl', 718 | router__pb2.XImportMissionControlRequest.SerializeToString, 719 | router__pb2.XImportMissionControlResponse.FromString, 720 | options, 721 | channel_credentials, 722 | insecure, 723 | call_credentials, 724 | compression, 725 | wait_for_ready, 726 | timeout, 727 | metadata, 728 | _registered_method=True) 729 | 730 | @staticmethod 731 | def GetMissionControlConfig(request, 732 | target, 733 | options=(), 734 | channel_credentials=None, 735 | call_credentials=None, 736 | insecure=False, 737 | compression=None, 738 | wait_for_ready=None, 739 | timeout=None, 740 | metadata=None): 741 | return grpc.experimental.unary_unary( 742 | request, 743 | target, 744 | '/routerrpc.Router/GetMissionControlConfig', 745 | router__pb2.GetMissionControlConfigRequest.SerializeToString, 746 | router__pb2.GetMissionControlConfigResponse.FromString, 747 | options, 748 | channel_credentials, 749 | insecure, 750 | call_credentials, 751 | compression, 752 | wait_for_ready, 753 | timeout, 754 | metadata, 755 | _registered_method=True) 756 | 757 | @staticmethod 758 | def SetMissionControlConfig(request, 759 | target, 760 | options=(), 761 | channel_credentials=None, 762 | call_credentials=None, 763 | insecure=False, 764 | compression=None, 765 | wait_for_ready=None, 766 | timeout=None, 767 | metadata=None): 768 | return grpc.experimental.unary_unary( 769 | request, 770 | target, 771 | '/routerrpc.Router/SetMissionControlConfig', 772 | router__pb2.SetMissionControlConfigRequest.SerializeToString, 773 | router__pb2.SetMissionControlConfigResponse.FromString, 774 | options, 775 | channel_credentials, 776 | insecure, 777 | call_credentials, 778 | compression, 779 | wait_for_ready, 780 | timeout, 781 | metadata, 782 | _registered_method=True) 783 | 784 | @staticmethod 785 | def QueryProbability(request, 786 | target, 787 | options=(), 788 | channel_credentials=None, 789 | call_credentials=None, 790 | insecure=False, 791 | compression=None, 792 | wait_for_ready=None, 793 | timeout=None, 794 | metadata=None): 795 | return grpc.experimental.unary_unary( 796 | request, 797 | target, 798 | '/routerrpc.Router/QueryProbability', 799 | router__pb2.QueryProbabilityRequest.SerializeToString, 800 | router__pb2.QueryProbabilityResponse.FromString, 801 | options, 802 | channel_credentials, 803 | insecure, 804 | call_credentials, 805 | compression, 806 | wait_for_ready, 807 | timeout, 808 | metadata, 809 | _registered_method=True) 810 | 811 | @staticmethod 812 | def BuildRoute(request, 813 | target, 814 | options=(), 815 | channel_credentials=None, 816 | call_credentials=None, 817 | insecure=False, 818 | compression=None, 819 | wait_for_ready=None, 820 | timeout=None, 821 | metadata=None): 822 | return grpc.experimental.unary_unary( 823 | request, 824 | target, 825 | '/routerrpc.Router/BuildRoute', 826 | router__pb2.BuildRouteRequest.SerializeToString, 827 | router__pb2.BuildRouteResponse.FromString, 828 | options, 829 | channel_credentials, 830 | insecure, 831 | call_credentials, 832 | compression, 833 | wait_for_ready, 834 | timeout, 835 | metadata, 836 | _registered_method=True) 837 | 838 | @staticmethod 839 | def SubscribeHtlcEvents(request, 840 | target, 841 | options=(), 842 | channel_credentials=None, 843 | call_credentials=None, 844 | insecure=False, 845 | compression=None, 846 | wait_for_ready=None, 847 | timeout=None, 848 | metadata=None): 849 | return grpc.experimental.unary_stream( 850 | request, 851 | target, 852 | '/routerrpc.Router/SubscribeHtlcEvents', 853 | router__pb2.SubscribeHtlcEventsRequest.SerializeToString, 854 | router__pb2.HtlcEvent.FromString, 855 | options, 856 | channel_credentials, 857 | insecure, 858 | call_credentials, 859 | compression, 860 | wait_for_ready, 861 | timeout, 862 | metadata, 863 | _registered_method=True) 864 | 865 | @staticmethod 866 | def SendPayment(request, 867 | target, 868 | options=(), 869 | channel_credentials=None, 870 | call_credentials=None, 871 | insecure=False, 872 | compression=None, 873 | wait_for_ready=None, 874 | timeout=None, 875 | metadata=None): 876 | return grpc.experimental.unary_stream( 877 | request, 878 | target, 879 | '/routerrpc.Router/SendPayment', 880 | router__pb2.SendPaymentRequest.SerializeToString, 881 | router__pb2.PaymentStatus.FromString, 882 | options, 883 | channel_credentials, 884 | insecure, 885 | call_credentials, 886 | compression, 887 | wait_for_ready, 888 | timeout, 889 | metadata, 890 | _registered_method=True) 891 | 892 | @staticmethod 893 | def TrackPayment(request, 894 | target, 895 | options=(), 896 | channel_credentials=None, 897 | call_credentials=None, 898 | insecure=False, 899 | compression=None, 900 | wait_for_ready=None, 901 | timeout=None, 902 | metadata=None): 903 | return grpc.experimental.unary_stream( 904 | request, 905 | target, 906 | '/routerrpc.Router/TrackPayment', 907 | router__pb2.TrackPaymentRequest.SerializeToString, 908 | router__pb2.PaymentStatus.FromString, 909 | options, 910 | channel_credentials, 911 | insecure, 912 | call_credentials, 913 | compression, 914 | wait_for_ready, 915 | timeout, 916 | metadata, 917 | _registered_method=True) 918 | 919 | @staticmethod 920 | def HtlcInterceptor(request_iterator, 921 | target, 922 | options=(), 923 | channel_credentials=None, 924 | call_credentials=None, 925 | insecure=False, 926 | compression=None, 927 | wait_for_ready=None, 928 | timeout=None, 929 | metadata=None): 930 | return grpc.experimental.stream_stream( 931 | request_iterator, 932 | target, 933 | '/routerrpc.Router/HtlcInterceptor', 934 | router__pb2.ForwardHtlcInterceptResponse.SerializeToString, 935 | router__pb2.ForwardHtlcInterceptRequest.FromString, 936 | options, 937 | channel_credentials, 938 | insecure, 939 | call_credentials, 940 | compression, 941 | wait_for_ready, 942 | timeout, 943 | metadata, 944 | _registered_method=True) 945 | 946 | @staticmethod 947 | def UpdateChanStatus(request, 948 | target, 949 | options=(), 950 | channel_credentials=None, 951 | call_credentials=None, 952 | insecure=False, 953 | compression=None, 954 | wait_for_ready=None, 955 | timeout=None, 956 | metadata=None): 957 | return grpc.experimental.unary_unary( 958 | request, 959 | target, 960 | '/routerrpc.Router/UpdateChanStatus', 961 | router__pb2.UpdateChanStatusRequest.SerializeToString, 962 | router__pb2.UpdateChanStatusResponse.FromString, 963 | options, 964 | channel_credentials, 965 | insecure, 966 | call_credentials, 967 | compression, 968 | wait_for_ready, 969 | timeout, 970 | metadata, 971 | _registered_method=True) 972 | -------------------------------------------------------------------------------- /lnd.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import codecs 3 | import os 4 | from functools import lru_cache 5 | from os.path import expanduser 6 | 7 | import grpc 8 | 9 | from grpc_generated import router_pb2 as lnrouter 10 | from grpc_generated import router_pb2_grpc as lnrouterrpc 11 | from grpc_generated import lightning_pb2 as ln 12 | from grpc_generated import lightning_pb2_grpc as lnrpc 13 | from grpc_generated import invoices_pb2 as invoices 14 | from grpc_generated import invoices_pb2_grpc as invoicesrpc 15 | 16 | MESSAGE_SIZE_MB = 50 * 1024 * 1024 17 | 18 | 19 | class Lnd: 20 | def __init__(self, lnd_dir, server, network): 21 | os.environ["GRPC_SSL_CIPHER_SUITES"] = "HIGH+ECDSA" 22 | if lnd_dir == "_DEFAULT_": 23 | lnd_dir = "~/.lnd" 24 | lnd_dir2 = "~/umbrel/lnd" 25 | lnd_dir3 = "~/umbrel/app-data/lightning/data/lnd" 26 | lnd_dir = expanduser(lnd_dir) 27 | lnd_dir2 = expanduser(lnd_dir2) 28 | lnd_dir3 = expanduser(lnd_dir3) 29 | if not os.path.isdir(lnd_dir) and os.path.isdir(lnd_dir2): 30 | lnd_dir = lnd_dir2 31 | if not os.path.isdir(lnd_dir) and os.path.isdir(lnd_dir3): 32 | lnd_dir = lnd_dir3 33 | else: 34 | lnd_dir = expanduser(lnd_dir) 35 | 36 | combined_credentials = self.get_credentials(lnd_dir, network) 37 | channel_options = [ 38 | ("grpc.max_message_length", MESSAGE_SIZE_MB), 39 | ("grpc.max_receive_message_length", MESSAGE_SIZE_MB), 40 | ] 41 | grpc_channel = grpc.secure_channel( 42 | server, combined_credentials, channel_options 43 | ) 44 | self.stub = lnrpc.LightningStub(grpc_channel) 45 | self.router_stub = lnrouterrpc.RouterStub(grpc_channel) 46 | self.invoices_stub = invoicesrpc.InvoicesStub(grpc_channel) 47 | 48 | @staticmethod 49 | def get_credentials(lnd_dir, network): 50 | with open(f"{lnd_dir}/tls.cert", "rb") as f: 51 | tls_certificate = f.read() 52 | ssl_credentials = grpc.ssl_channel_credentials(tls_certificate) 53 | with open(f"{lnd_dir}/data/chain/bitcoin/{network}/admin.macaroon", "rb") as f: 54 | macaroon = codecs.encode(f.read(), "hex") 55 | auth_credentials = grpc.metadata_call_credentials( 56 | lambda _, callback: callback([("macaroon", macaroon)], None) 57 | ) 58 | combined_credentials = grpc.composite_channel_credentials( 59 | ssl_credentials, auth_credentials 60 | ) 61 | return combined_credentials 62 | 63 | @lru_cache(maxsize=None) 64 | def get_info(self): 65 | return self.stub.GetInfo(ln.GetInfoRequest()) 66 | 67 | @lru_cache(maxsize=None) 68 | def get_node_alias(self, pub_key): 69 | return self.stub.GetNodeInfo( 70 | ln.NodeInfoRequest(pub_key=pub_key, include_channels=False) 71 | ).node.alias 72 | 73 | def get_own_pubkey(self): 74 | return self.get_info().identity_pubkey 75 | 76 | def generate_invoice(self, memo, amount): 77 | invoice_request = ln.Invoice( 78 | memo=memo, 79 | value=amount, 80 | ) 81 | add_invoice_response = self.stub.AddInvoice(invoice_request) 82 | return self.decode_payment_request(add_invoice_response.payment_request) 83 | 84 | def cancel_invoice(self, payment_hash): 85 | payment_hash_bytes = self.hex_string_to_bytes(payment_hash) 86 | return self.invoices_stub.CancelInvoice(invoices.CancelInvoiceMsg(payment_hash=payment_hash_bytes)) 87 | 88 | def decode_payment_request(self, payment_request): 89 | request = ln.PayReqString( 90 | pay_req=payment_request, 91 | ) 92 | return self.stub.DecodePayReq(request) 93 | 94 | @lru_cache(maxsize=None) 95 | def get_channels(self, active_only=False, public_only=False, private_only=False): 96 | channels = self.stub.ListChannels(ln.ListChannelsRequest(active_only=active_only,public_only=public_only,private_only=private_only)).channels 97 | return [c for c in channels if self.is_zombie(c.chan_id) is False] 98 | 99 | @lru_cache(maxsize=None) 100 | def get_max_channel_capacity(self): 101 | max_channel_capacity = 0 102 | for channel in self.get_channels(active_only=False): 103 | if channel.capacity > max_channel_capacity: 104 | max_channel_capacity = channel.capacity 105 | return max_channel_capacity 106 | 107 | def get_route( 108 | self, 109 | pub_key, 110 | amount, 111 | ignored_pairs, 112 | ignored_nodes, 113 | first_hop_channel_id, 114 | fee_limit_msat, 115 | ): 116 | if fee_limit_msat: 117 | fee_limit = {"fixed_msat": int(fee_limit_msat)} 118 | else: 119 | fee_limit = None 120 | if pub_key: 121 | last_hop_pubkey = base64.b16decode(pub_key, True) 122 | else: 123 | last_hop_pubkey = None 124 | request = ln.QueryRoutesRequest( 125 | pub_key=self.get_own_pubkey(), 126 | last_hop_pubkey=last_hop_pubkey, 127 | amt=amount, 128 | ignored_pairs=ignored_pairs, 129 | fee_limit=fee_limit, 130 | ignored_nodes=ignored_nodes, 131 | use_mission_control=True, 132 | outgoing_chan_id=first_hop_channel_id, 133 | time_pref=-1 134 | ) 135 | try: 136 | response = self.stub.QueryRoutes(request) 137 | return response.routes 138 | except: 139 | return None 140 | 141 | @lru_cache(maxsize=None) 142 | def get_edge(self, channel_id): 143 | try: 144 | return self.stub.GetChanInfo(ln.ChanInfoRequest(chan_id=channel_id)) 145 | except Exception: 146 | print(f"Unable to find channel edge {channel_id}") 147 | raise 148 | 149 | def get_policy_to(self, channel_id): 150 | edge = self.get_edge(channel_id) 151 | # node1_policy contains the fee base and rate for payments from node1 to node2 152 | if edge.node1_pub == self.get_own_pubkey(): 153 | return edge.node1_policy 154 | return edge.node2_policy 155 | 156 | def get_policy_from(self, channel_id): 157 | edge = self.get_edge(channel_id) 158 | # node1_policy contains the fee base and rate for payments from node1 to node2 159 | if edge.node1_pub == self.get_own_pubkey(): 160 | return edge.node2_policy 161 | return edge.node1_policy 162 | 163 | def get_ppm_to(self, channel_id): 164 | return self.get_policy_to(channel_id).fee_rate_milli_msat 165 | 166 | def get_ppm_from(self, channel_id): 167 | return self.get_policy_from(channel_id).fee_rate_milli_msat 168 | 169 | def send_payment(self, payment_request, route): 170 | last_hop = route.hops[-1] 171 | last_hop.mpp_record.payment_addr = payment_request.payment_addr 172 | last_hop.mpp_record.total_amt_msat = payment_request.num_msat 173 | request = lnrouter.SendToRouteRequest(route=route) 174 | request.payment_hash = self.hex_string_to_bytes(payment_request.payment_hash) 175 | return self.router_stub.SendToRoute(request) 176 | 177 | @staticmethod 178 | def hex_string_to_bytes(hex_string): 179 | decode_hex = codecs.getdecoder("hex_codec") 180 | return decode_hex(hex_string)[0] 181 | 182 | @lru_cache(maxsize=None) 183 | def is_zombie(self, channel_id): 184 | try: 185 | self.get_edge(channel_id) 186 | except: 187 | print(f"Unable to load channel {channel_id}!") 188 | return True 189 | return False 190 | -------------------------------------------------------------------------------- /logic.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | import output 4 | from output import Output, format_alias, format_fee_msat, format_ppm, format_amount, \ 5 | format_warning, format_error, format_earning, format_fee_msat_red, format_fee_msat_white, format_channel_id 6 | from routes import Routes 7 | 8 | DEFAULT_BASE_FEE_SAT_MSAT = 1_000 9 | DEFAULT_FEE_RATE_MSAT = 0.001 10 | MAX_FEE_RATE = 2_000 11 | 12 | 13 | class Logic: 14 | def __init__( 15 | self, 16 | lnd, 17 | first_hop_channel, 18 | last_hop_channel, 19 | amount, 20 | excluded, 21 | fee_factor, 22 | fee_limit_sat, 23 | fee_ppm_limit, 24 | min_local, 25 | min_remote, 26 | output: Output, 27 | reckless, 28 | ignore_missed_fee 29 | ): 30 | self.lnd = lnd 31 | self.first_hop_channel = first_hop_channel 32 | self.last_hop_channel = last_hop_channel 33 | self.amount = amount 34 | self.excluded = excluded 35 | self.fee_factor = fee_factor 36 | self.fee_limit_sat = fee_limit_sat 37 | self.fee_ppm_limit = fee_ppm_limit 38 | self.min_local = min_local 39 | self.min_remote = min_remote 40 | self.output = output 41 | self.reckless = reckless 42 | self.ignore_missed_fee = ignore_missed_fee 43 | if not self.fee_factor: 44 | self.fee_factor = 1.0 45 | 46 | def rebalance(self): 47 | first_hop_alias_formatted = "" 48 | last_hop_alias_formatted = "" 49 | first_channel_id = 0 50 | if self.first_hop_channel: 51 | first_hop_alias_formatted = format_alias(self.lnd.get_node_alias(self.first_hop_channel.remote_pubkey)) 52 | first_channel_id = format_channel_id(self.first_hop_channel.chan_id) 53 | if self.last_hop_channel: 54 | last_hop_alias_formatted = format_alias(self.lnd.get_node_alias(self.last_hop_channel.remote_pubkey)) 55 | amount_formatted = format_amount(self.amount) 56 | if self.first_hop_channel and self.last_hop_channel: 57 | self.output.print_line( 58 | f"Sending {amount_formatted} satoshis from channel {first_channel_id} with {first_hop_alias_formatted} " 59 | f"back through {last_hop_alias_formatted}." 60 | ) 61 | elif self.last_hop_channel: 62 | self.output.print_line(f"Sending {amount_formatted} satoshis back through {last_hop_alias_formatted}.") 63 | else: 64 | self.output.print_line( 65 | f"Sending {self.amount:,} satoshis from channel {first_channel_id} with {first_hop_alias_formatted}." 66 | ) 67 | 68 | fee_limit_msat = self.get_fee_limit_msat() 69 | payment_request = self.generate_invoice() 70 | min_fee_last_hop = None 71 | if self.first_hop_channel: 72 | fee_rate_first_hop = self.lnd.get_ppm_to(self.first_hop_channel.chan_id) 73 | policy_first_hop = self.lnd.get_policy_to(self.first_hop_channel.chan_id) 74 | min_fee_last_hop = self.compute_fee(self.amount, fee_rate_first_hop, policy_first_hop) 75 | routes = Routes( 76 | self.lnd, 77 | payment_request, 78 | self.first_hop_channel, 79 | self.last_hop_channel, 80 | fee_limit_msat, 81 | self.output 82 | ) 83 | 84 | self.initialize_ignored_channels(routes, fee_limit_msat, min_fee_last_hop) 85 | 86 | tried_routes = [] 87 | while routes.has_next(): 88 | route = routes.get_next() 89 | 90 | success = self.try_route(payment_request, route, routes, tried_routes) 91 | if success: 92 | return True 93 | self.output.print_line("Could not find any suitable route") 94 | self.lnd.cancel_invoice(payment_request.payment_hash) 95 | return False 96 | 97 | def get_fee_limit_msat(self): 98 | fee_limit_msat = None 99 | if self.fee_limit_sat: 100 | fee_limit_msat = self.fee_limit_sat * 1_000 101 | elif self.fee_ppm_limit: 102 | if self.reckless: 103 | fee_limit_msat = self.fee_ppm_limit * self.amount / 1_000 104 | else: 105 | fee_limit_msat = max(1_000, self.fee_ppm_limit * self.amount / 1_000) 106 | elif not self.last_hop_channel: 107 | return None 108 | 109 | if self.last_hop_channel and not self.reckless: 110 | fee_rate = self.lnd.get_ppm_to(self.last_hop_channel.chan_id) 111 | if fee_rate > MAX_FEE_RATE: 112 | last_hop_alias = self.lnd.get_node_alias(self.last_hop_channel.remote_pubkey) 113 | self.output.print_line( 114 | f"Calculating using capped fee rate {MAX_FEE_RATE} " 115 | f"for inbound channel (with {last_hop_alias}, original fee rate {fee_rate})" 116 | ) 117 | fee_rate = MAX_FEE_RATE 118 | policy = self.lnd.get_policy_to(self.last_hop_channel.chan_id) 119 | if fee_limit_msat: 120 | fee_limit_msat = min( 121 | fee_limit_msat, 122 | self.compute_fee(self.amount, self.fee_factor * fee_rate, policy) * 1_000 123 | ) 124 | else: 125 | fee_limit_msat = self.compute_fee(self.amount, self.fee_factor * fee_rate, policy) * 1_000 126 | if not self.reckless: 127 | fee_limit_msat = max(1_000, fee_limit_msat) 128 | 129 | ppm_limit = int(fee_limit_msat / self.amount * 1_000) 130 | 131 | fee_limit_formatted = format_fee_msat(int(fee_limit_msat)) 132 | ppm_limit_formatted = format_ppm(ppm_limit) 133 | self.output.print_without_linebreak(f"Setting fee limit to {fee_limit_formatted} ({ppm_limit_formatted})") 134 | if self.fee_factor != 1.0: 135 | self.output.print_line(f" (factor {self.fee_factor}).") 136 | else: 137 | self.output.print_line(".") 138 | 139 | return fee_limit_msat 140 | 141 | def try_route(self, payment_request, route, routes, tried_routes): 142 | if self.route_is_invalid(route, routes): 143 | return False 144 | 145 | tried_routes.append(route) 146 | self.output.print_line("") 147 | self.output.print_without_linebreak(f"Trying route #{len(tried_routes)}") 148 | fee_msat = route.total_fees_msat 149 | route_ppm = int(route.total_fees_msat * 1_000_000 / route.total_amt_msat) 150 | fee_formatted = format_fee_msat(fee_msat) 151 | ppm_formatted = format_ppm(route_ppm) 152 | self.output.print_line(f" (fee {fee_formatted}, {ppm_formatted})") 153 | self.output.print_route(route) 154 | 155 | response = self.lnd.send_payment(payment_request, route) 156 | is_successful = response.failure.code == 0 157 | if is_successful: 158 | self.print_success_statistics(route, route_ppm) 159 | return True 160 | else: 161 | self.handle_error(response, route, routes) 162 | return False 163 | 164 | def print_success_statistics(self, route, route_ppm): 165 | first_hop = route.hops[0] 166 | last_hop = route.hops[-1] 167 | amount = last_hop.amt_to_forward 168 | amount_msat = last_hop.amt_to_forward_msat 169 | rebalance_fee_msat = route.total_fees_msat 170 | policy_last_hop = self.lnd.get_policy_to(last_hop.chan_id) 171 | policy_first_hop = self.lnd.get_policy_to(first_hop.chan_id) 172 | 173 | last_hop_alias = self.lnd.get_node_alias(route.hops[-2].pub_key) 174 | first_hop_alias = self.lnd.get_node_alias(first_hop.pub_key) 175 | first_hop_ppm = self.lnd.get_ppm_to(first_hop.chan_id) 176 | last_hop_ppm = self.lnd.get_ppm_to(last_hop.chan_id) 177 | self.output.print_line("") 178 | self.output.print_line(output.format_success( 179 | f"Increased outbound liquidity on {last_hop_alias} ({last_hop_ppm:,}ppm) " 180 | f"by {int(amount):,} sat" 181 | )) 182 | self.output.print_line(output.format_success( 183 | f"Increased inbound liquidity on {first_hop_alias} ({first_hop_ppm:,}ppm configured for outbound)") 184 | ) 185 | self.output.print_line(output.format_success( 186 | f"Fee: {route.total_fees:,} sats ({rebalance_fee_msat :,} mSAT, {route_ppm:,}ppm)" 187 | )) 188 | self.output.print_line("") 189 | self.output.print_line(output.format_success("Successful route:")) 190 | self.output.print_route(route) 191 | 192 | if self.ignore_missed_fee: 193 | missed_fee_msat = 0 194 | else: 195 | missed_fee_msat = self.compute_fee( 196 | amount_msat / 1_000, policy_first_hop.fee_rate_milli_msat, policy_first_hop 197 | ) * 1_000 198 | expected_income_msat = self.compute_fee( 199 | amount_msat / 1_000, policy_last_hop.fee_rate_milli_msat, policy_last_hop 200 | ) * 1_000 201 | difference_msat = -rebalance_fee_msat - missed_fee_msat + expected_income_msat 202 | future_income_formatted = format_earning(math.floor(expected_income_msat), 8) 203 | self.output.print_line( 204 | f" {future_income_formatted}: " 205 | f"expected future fee income for inbound channel (with {last_hop_alias})" 206 | ) 207 | missed_fee_formatted = format_fee_msat(math.ceil(missed_fee_msat), 8) 208 | difference_formatted = format_fee_msat_white(math.ceil(difference_msat), 8) 209 | transaction_fees_formatted = format_fee_msat(int(rebalance_fee_msat), 8) 210 | self.output.print_line(f"- {transaction_fees_formatted}: rebalance transaction fees") 211 | if not self.ignore_missed_fee: 212 | self.output.print_line( 213 | f"- {missed_fee_formatted}: " 214 | f"missing out on future fees for outbound channel (with {first_hop_alias})" 215 | ) 216 | self.output.print_line(f"= {difference_formatted}: potential profit!") 217 | 218 | def handle_error(self, response, route, routes): 219 | code = response.failure.code 220 | failure_source_pubkey = Logic.get_failure_source_pubkey(response, route) 221 | if code == 15: 222 | self.output.print_line(format_warning("Temporary channel failure")) 223 | routes.ignore_edge_on_route(failure_source_pubkey, route) 224 | elif code == 18: 225 | self.output.print_line(format_warning("Unknown next peer")) 226 | routes.ignore_edge_on_route(failure_source_pubkey, route) 227 | elif code == 12: 228 | self.output.print_line(format_warning("Fee insufficient")) 229 | elif code == 14: 230 | self.output.print_line(format_warning("Channel disabled")) 231 | routes.ignore_edge_on_route(failure_source_pubkey, route) 232 | elif code == 13: 233 | self.output.print_line(format_warning("Incorrect CLTV expiry")) 234 | routes.ignore_edge_on_route(failure_source_pubkey, route) 235 | else: 236 | self.output.print_line(format_error(f"Unknown error code {repr(code)}:")) 237 | self.output.print_line(format_error(repr(response))) 238 | 239 | @staticmethod 240 | def get_failure_source_pubkey(response, route): 241 | if response.failure.failure_source_index == 0: 242 | failure_source_pubkey = route.hops[-1].pub_key 243 | else: 244 | failure_source_pubkey = route.hops[ 245 | response.failure.failure_source_index - 1 246 | ].pub_key 247 | return failure_source_pubkey 248 | 249 | def route_is_invalid(self, route, routes): 250 | first_hop = route.hops[0] 251 | last_hop = route.hops[-1] 252 | if self.low_outbound_liquidity_after_sending(first_hop, route.total_amt): 253 | self.output.print_without_linebreak("Outbound channel would have low local ratio after sending, ") 254 | routes.ignore_first_hop(self.get_channel_for_channel_id(first_hop.chan_id)) 255 | return True 256 | if self.first_hop_and_last_hop_use_same_channel(first_hop, last_hop): 257 | self.output.print_without_linebreak("Outbound and inbound channel are identical, ") 258 | hop_before_last_hop = route.hops[-2] 259 | routes.ignore_edge_from_to( 260 | last_hop.chan_id, hop_before_last_hop.pub_key, last_hop.pub_key 261 | ) 262 | return True 263 | if self.low_inbound_liquidity_after_receiving(last_hop): 264 | self.output.print_without_linebreak( 265 | "Inbound channel would have high local ratio after receiving, " 266 | ) 267 | hop_before_last_hop = route.hops[-2] 268 | routes.ignore_edge_from_to( 269 | last_hop.chan_id, hop_before_last_hop.pub_key, last_hop.pub_key 270 | ) 271 | return True 272 | return self.fees_too_high(route, routes) 273 | 274 | def low_outbound_liquidity_after_sending(self, first_hop, total_amount): 275 | if self.first_hop_channel: 276 | # Just use the computed/specified amount to drain the first hop, ignoring fees 277 | return False 278 | channel_id = first_hop.chan_id 279 | channel = self.get_channel_for_channel_id(channel_id) 280 | if channel is None: 281 | self.output.print_line(f"Unable to get channel information for hop {repr(first_hop)}") 282 | return True 283 | 284 | return max(0, channel.local_balance - channel.local_chan_reserve_sat) - total_amount < self.min_local 285 | 286 | def low_inbound_liquidity_after_receiving(self, last_hop): 287 | if self.last_hop_channel: 288 | return False 289 | channel_id = last_hop.chan_id 290 | channel = self.get_channel_for_channel_id(channel_id) 291 | if channel is None: 292 | self.output.print_line(f"Unable to get channel information for hop {repr(last_hop)}") 293 | return True 294 | 295 | amount = last_hop.amt_to_forward 296 | return max(0, channel.remote_balance - channel.remote_chan_reserve_sat) - amount < self.min_remote 297 | 298 | @staticmethod 299 | def first_hop_and_last_hop_use_same_channel(first_hop, last_hop): 300 | return first_hop.chan_id == last_hop.chan_id 301 | 302 | def fees_too_high(self, route, routes): 303 | policy_first_hop = self.lnd.get_policy_to(route.hops[0].chan_id) 304 | amount_msat = route.total_amt_msat 305 | if self.ignore_missed_fee: 306 | missed_fee_msat = 0 307 | else: 308 | missed_fee_msat = self.compute_fee( 309 | amount_msat / 1_000, policy_first_hop.fee_rate_milli_msat, policy_first_hop 310 | ) * 1_000 311 | policy_last_hop = self.lnd.get_policy_to(route.hops[-1].chan_id) 312 | fee_rate_last_hop = policy_last_hop.fee_rate_milli_msat 313 | original_fee_rate_last_hop = fee_rate_last_hop 314 | if fee_rate_last_hop > MAX_FEE_RATE: 315 | fee_rate_last_hop = MAX_FEE_RATE 316 | 317 | last_hop = route.hops[-1] 318 | amount_arriving_at_target_channel = last_hop.amt_to_forward_msat 319 | expected_income_msat = self.fee_factor * self.compute_fee( 320 | amount_arriving_at_target_channel / 1_000, fee_rate_last_hop, policy_last_hop 321 | ) * 1_000 322 | rebalance_fee_msat = route.total_fees_msat 323 | high_fees = rebalance_fee_msat + missed_fee_msat > expected_income_msat 324 | if high_fees: 325 | if self.reckless: 326 | self.output.print_line(format_error("Considering route with high fees")) 327 | return False 328 | difference_msat = -rebalance_fee_msat - missed_fee_msat + expected_income_msat 329 | first_hop_alias = format_alias(self.lnd.get_node_alias(route.hops[0].pub_key)) 330 | last_hop_alias = format_alias(self.lnd.get_node_alias(route.hops[-2].pub_key)) 331 | self.output.print_line("") 332 | if fee_rate_last_hop != original_fee_rate_last_hop: 333 | self.output.print_line( 334 | f"Calculating using capped fee rate {MAX_FEE_RATE} for inbound channel " 335 | f"(with {last_hop_alias}, original fee rate {original_fee_rate_last_hop})" 336 | ) 337 | route_ppm = int(route.total_fees_msat * 1_000_000 / route.total_amt_msat) 338 | route_fee_formatted = format_fee_msat(rebalance_fee_msat) 339 | route_ppm_formatted = format_ppm(route_ppm) 340 | self.output.print_line( 341 | f"Skipping route due to high fees " 342 | f"(fee {route_fee_formatted}, {route_ppm_formatted})" 343 | ) 344 | self.output.print_route(route) 345 | future_income_formatted = format_earning(math.floor(expected_income_msat), 8) 346 | self.output.print_without_linebreak( 347 | f" {future_income_formatted}: " 348 | f"expected future fee income for inbound channel (with {last_hop_alias})" 349 | ) 350 | if self.fee_factor != 1.0: 351 | self.output.print_line(f" (factor {self.fee_factor})") 352 | else: 353 | self.output.print_line("") 354 | transaction_fees_formatted = format_fee_msat(int(rebalance_fee_msat), 8) 355 | missed_fee_formatted = format_fee_msat(math.ceil(missed_fee_msat), 8) 356 | difference_formatted = format_fee_msat_red(math.ceil(difference_msat), 8) 357 | self.output.print_line(f"- {transaction_fees_formatted}: rebalance transaction fees") 358 | if not self.ignore_missed_fee: 359 | self.output.print_line(f"- {missed_fee_formatted}: " 360 | f"missing out on future fees for outbound channel (with {first_hop_alias})") 361 | self.output.print_line(f"= {difference_formatted}") 362 | routes.ignore_high_fee_hops(route) 363 | return high_fees 364 | 365 | @staticmethod 366 | def compute_fee(amount_sat, fee_rate, policy): 367 | expected_fee = amount_sat / 1_000_000 * fee_rate + policy.fee_base_msat / 1_000 368 | return expected_fee 369 | 370 | def generate_invoice(self): 371 | if self.last_hop_channel: 372 | memo = f"Rebalance of channel with ID {self.last_hop_channel.chan_id}" 373 | else: 374 | memo = f"Rebalance of channel with ID {self.first_hop_channel.chan_id}" 375 | return self.lnd.generate_invoice(memo, self.amount) 376 | 377 | def get_channel_for_channel_id(self, channel_id, clear_cache_if_not_found=True): 378 | for channel in self.lnd.get_channels(): 379 | if channel.chan_id == channel_id: 380 | if not hasattr(channel, "local_balance"): 381 | channel.local_balance = 0 382 | if not hasattr(channel, "remote_balance"): 383 | channel.remote_balance = 0 384 | return channel 385 | if clear_cache_if_not_found: 386 | self.lnd.get_channels.cache_clear() 387 | return self.get_channel_for_channel_id(channel_id, clear_cache_if_not_found=False) 388 | raise Exception(f"Unable to find channel with id {channel_id}!") 389 | 390 | def initialize_ignored_channels(self, routes, fee_limit_msat, min_fee_last_hop): 391 | if self.reckless: 392 | self.output.print_line(format_error("Also considering economically unviable channels for routes.")) 393 | for chan_id in self.excluded: 394 | self.output.print_line(f"Channel {format_channel_id(chan_id)} is excluded:") 395 | routes.ignore_channel(chan_id) 396 | if self.first_hop_channel: 397 | if min_fee_last_hop and not self.reckless: 398 | self.ignore_cheap_channels_for_last_hop(min_fee_last_hop, routes) 399 | if not self.last_hop_channel and not self.reckless: 400 | self.ignore_last_hops_with_low_inbound(routes) 401 | 402 | # avoid me - X - me via the same channel/peer 403 | chan_id = self.first_hop_channel.chan_id 404 | from_pub_key = self.first_hop_channel.remote_pubkey 405 | to_pub_key = self.lnd.get_own_pubkey() 406 | routes.ignore_edge_from_to( 407 | chan_id, from_pub_key, to_pub_key, show_message=False 408 | ) 409 | if self.last_hop_channel: 410 | if not self.reckless: 411 | self.ignore_first_hops_with_fee_rate_higher_than_last_hop(routes) 412 | # avoid me - X - me via the same channel/peer 413 | chan_id = self.last_hop_channel.chan_id 414 | from_pub_key = self.lnd.get_own_pubkey() 415 | to_pub_key = self.last_hop_channel.remote_pubkey 416 | routes.ignore_edge_from_to( 417 | chan_id, from_pub_key, to_pub_key, show_message=False 418 | ) 419 | if self.last_hop_channel and fee_limit_msat and not self.reckless: 420 | # ignore first hops with high fee rate configured by our node (causing high missed future fees) 421 | max_fee_rate_first_hop = math.ceil(fee_limit_msat * 1_000 / self.amount) 422 | for channel in self.lnd.get_channels(): 423 | try: 424 | fee_rate = self.lnd.get_ppm_to(channel.chan_id) 425 | if fee_rate > max_fee_rate_first_hop and self.first_hop_channel != channel: 426 | routes.ignore_first_hop(channel, show_message=False) 427 | except: 428 | pass 429 | for channel in self.lnd.get_channels(): 430 | if self.low_outbound_liquidity_after_sending(channel, self.amount): 431 | routes.ignore_first_hop(channel, show_message=False) 432 | 433 | def ignore_cheap_channels_for_last_hop(self, min_fee_last_hop, routes): 434 | for channel in self.lnd.get_channels(): 435 | channel_id = channel.chan_id 436 | try: 437 | ppm = self.lnd.get_ppm_to(channel_id) 438 | policy = self.lnd.get_policy_to(channel_id) 439 | fee = self.compute_fee(self.amount, self.fee_factor * ppm, policy) 440 | if fee < min_fee_last_hop: 441 | routes.ignore_edge_from_to( 442 | channel_id, channel.remote_pubkey, self.lnd.get_own_pubkey(), show_message=False 443 | ) 444 | except: 445 | pass 446 | 447 | def ignore_first_hops_with_fee_rate_higher_than_last_hop(self, routes): 448 | last_hop_fee_rate = self.lnd.get_ppm_to(self.last_hop_channel.chan_id) 449 | from_pub_key = self.lnd.get_own_pubkey() 450 | for channel in self.lnd.get_channels(): 451 | chan_id = channel.chan_id 452 | try: 453 | if self.lnd.get_ppm_to(chan_id) > last_hop_fee_rate: 454 | to_pub_key = channel.remote_pubkey 455 | routes.ignore_edge_from_to( 456 | chan_id, from_pub_key, to_pub_key, show_message=False 457 | ) 458 | except: 459 | pass 460 | 461 | def ignore_last_hops_with_low_inbound(self, routes): 462 | for channel in self.lnd.get_channels(): 463 | channel_id = channel.chan_id 464 | if channel is None: 465 | return 466 | if max(0, channel.remote_balance - channel.remote_chan_reserve_sat) - self.amount < self.min_remote: 467 | to_pub_key = self.lnd.get_own_pubkey() 468 | routes.ignore_edge_from_to( 469 | channel_id, channel.remote_pubkey, to_pub_key, show_message=False 470 | ) 471 | -------------------------------------------------------------------------------- /output.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from yachalk import chalk 4 | 5 | 6 | class Output: 7 | def __init__(self, lnd): 8 | self.lnd = lnd 9 | 10 | @staticmethod 11 | def print_line(message, end='\n'): 12 | sys.stdout.write(f"{message}{end}") 13 | 14 | @staticmethod 15 | def print_without_linebreak(message): 16 | sys.stdout.write(message) 17 | 18 | def print_route(self, route): 19 | route_str = "\n".join( 20 | self.get_channel_representation(h.chan_id, h.pub_key) + "\t" + 21 | self.get_fee_information(h, route) 22 | for h in route.hops 23 | ) 24 | self.print_line(route_str) 25 | 26 | def get_channel_representation(self, chan_id, pubkey_to, pubkey_from=None): 27 | channel_id_formatted = format_channel_id(chan_id) 28 | if pubkey_from: 29 | alias_to_formatted = format_alias(self.lnd.get_node_alias(pubkey_to)) 30 | alias_from = format_alias(self.lnd.get_node_alias(pubkey_from)) 31 | return f"{channel_id_formatted} ({alias_from} to {alias_to_formatted})" 32 | alias_to_formatted = format_alias(f"{self.lnd.get_node_alias(pubkey_to):32}") 33 | return f"{channel_id_formatted} to {alias_to_formatted}" 34 | 35 | def get_fee_information(self, next_hop, route): 36 | hops = list(route.hops) 37 | if hops[0] == next_hop: 38 | ppm = self.lnd.get_ppm_to(next_hop.chan_id) 39 | return f"(free, we usually charge {format_ppm(ppm)})" 40 | hop = hops[hops.index(next_hop) - 1] 41 | ppm = int(hop.fee_msat * 1_000_000 / hop.amt_to_forward_msat) 42 | fee_formatted = "fee " + chalk.cyan(f"{hop.fee_msat:8,} mSAT") 43 | ppm_formatted = format_ppm(ppm, 5) 44 | return f"({fee_formatted}, {ppm_formatted})" 45 | 46 | 47 | def format_alias(alias): 48 | if not sys.stdout.encoding.lower().startswith('utf'): 49 | alias = alias.encode('latin-1', 'ignore').decode() 50 | return chalk.bold(alias) 51 | 52 | 53 | def format_ppm(ppm, min_length=None): 54 | if min_length: 55 | return chalk.bold(f"{ppm:{min_length},}ppm") 56 | return chalk.bold(f"{ppm:,}ppm") 57 | 58 | 59 | def format_fee_msat(fee_msat, min_length=None): 60 | if min_length: 61 | return chalk.cyan(f"{fee_msat:{min_length},} mSAT") 62 | return chalk.cyan(f"{fee_msat:,} mSAT") 63 | 64 | 65 | def format_fee_msat_red(fee_msat, min_length=None): 66 | if min_length: 67 | return chalk.red(f"{fee_msat:{min_length},} mSAT") 68 | return chalk.red(f"{fee_msat:,} mSAT") 69 | 70 | 71 | def format_fee_msat_white(fee_msat, min_length=None): 72 | if min_length: 73 | return chalk.white_bright(f"{fee_msat:{min_length},} mSAT") 74 | return chalk.white_bright(f"{fee_msat:,} mSAT") 75 | 76 | 77 | def format_fee_sat(fee_sat): 78 | return chalk.cyan(f"{fee_sat:,} sats") 79 | 80 | 81 | def format_earning(msat, min_width=None): 82 | if min_width: 83 | return chalk.green(f"{msat:{min_width},} mSAT") 84 | return chalk.green(f"{msat:,} mSAT") 85 | 86 | 87 | def format_amount(amount, min_width=None): 88 | if min_width: 89 | return chalk.yellow(f"{amount:{min_width},}") 90 | return chalk.yellow(f"{amount:,}") 91 | 92 | 93 | def format_amount_green(amount, min_width=None): 94 | return chalk.green(f"{amount:{min_width},}") 95 | 96 | 97 | def format_boring_string(string): 98 | return chalk.bg_black(chalk.gray(string)) 99 | 100 | 101 | def format_success(string): 102 | return chalk.bg_cyan(chalk.white_bright(string)) 103 | 104 | 105 | def format_channel_id(channel_id): 106 | return format_boring_string(channel_id) 107 | 108 | 109 | def format_warning(warning): 110 | return chalk.yellow(warning) 111 | 112 | 113 | def format_error(error): 114 | return chalk.red(error) 115 | 116 | 117 | def print_bar(width, length): 118 | result = chalk.bold("[") 119 | if sys.stdout.encoding.lower().startswith('utf'): 120 | for _ in range(0, length): 121 | result += chalk.bold(u"\u2588") 122 | for _ in range(length, width): 123 | result += u"\u2591" 124 | else: 125 | for _ in range(0, length): 126 | result += chalk.bold("X") 127 | for _ in range(length, width): 128 | result += u"." 129 | result += chalk.bold("]") 130 | return result 131 | -------------------------------------------------------------------------------- /rebalance.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse 4 | import os 5 | import platform 6 | import random 7 | import sys 8 | 9 | from yachalk import chalk 10 | 11 | from lnd import Lnd 12 | from logic import Logic 13 | from output import Output, format_alias, format_ppm, format_amount, format_amount_green, format_boring_string, \ 14 | print_bar, format_channel_id, format_error 15 | 16 | class Rebalance: 17 | def __init__(self, arguments): 18 | self.lnd = Lnd(arguments.lnddir, arguments.grpc, arguments.network) 19 | self.output = Output(self.lnd) 20 | self.min_amount = arguments.min_amount 21 | self.arguments = arguments 22 | self.first_hop_channel_id = self.parse_channel_id(vars(arguments)["from"]) 23 | self.last_hop_channel_id = self.parse_channel_id(arguments.to) 24 | self.first_hop_channel = None 25 | self.last_hop_channel = None 26 | self.min_local = arguments.min_local 27 | self.min_remote = arguments.min_remote 28 | 29 | @staticmethod 30 | def parse_channel_id(id_string): 31 | if not id_string: 32 | return None 33 | arr = None 34 | if ":" in id_string: 35 | arr = id_string.rstrip().split(":") 36 | elif "x" in id_string: 37 | arr = id_string.rstrip().split("x") 38 | if arr: 39 | return (int(arr[0]) << 40) + (int(arr[1]) << 16) + int(arr[2]) 40 | return int(id_string) 41 | 42 | def get_sort_key(self, channel): 43 | rebalance_amount = self.get_rebalance_amount(channel) 44 | if rebalance_amount >= 0: 45 | return ( 46 | self.lnd.get_ppm_to(channel.chan_id) * rebalance_amount, 47 | get_remote_available(channel) - get_local_available(channel) 48 | ) 49 | return rebalance_amount, self.lnd.get_ppm_to(channel.chan_id) 50 | 51 | def get_scaled_min_local(self, channel): 52 | local_available = get_local_available(channel) 53 | remote_available = get_remote_available(channel) 54 | if local_available + remote_available >= self.min_local + self.min_remote: 55 | return self.min_local 56 | return self.min_local / (self.min_local + self.min_remote) * (local_available + remote_available) 57 | 58 | def get_scaled_min_remote(self, channel): 59 | local_available = get_local_available(channel) 60 | remote_available = get_remote_available(channel) 61 | if local_available + remote_available >= self.min_local + self.min_remote: 62 | return self.min_remote 63 | return self.min_remote / (self.min_local + self.min_remote) * (local_available + remote_available) 64 | 65 | def get_rebalance_amount(self, channel): 66 | local_available = get_local_available(channel) 67 | remote_available = get_remote_available(channel) 68 | too_small = local_available + remote_available < self.min_local + self.min_remote 69 | if too_small: 70 | return int(self.get_scaled_min_local(channel) - local_available) 71 | if local_available < self.min_local: 72 | return self.min_local - local_available 73 | if remote_available < self.min_remote: 74 | return remote_available - self.min_remote 75 | return 0 76 | 77 | def get_amount(self): 78 | amount = None 79 | if self.arguments.amount: 80 | amount = self.arguments.amount 81 | if not self.arguments.adjust_amount_to_limits: 82 | return amount 83 | 84 | should_send = 0 85 | can_send = 0 86 | if self.first_hop_channel: 87 | should_send = -self.get_rebalance_amount(self.first_hop_channel) 88 | can_send = self.get_amount_can_send(self.first_hop_channel) 89 | 90 | if can_send < 0: 91 | from_alias = self.lnd.get_node_alias(self.first_hop_channel.remote_pubkey) 92 | print( 93 | f"Error: source channel {format_channel_id(self.first_hop_channel.chan_id)} to " 94 | f"{format_alias(from_alias)} needs to {chalk.green('receive')} funds to be within bounds," 95 | f" you want it to {chalk.red('send')} funds. " 96 | "Specify amount manually if this was intended." 97 | ) 98 | return 0 99 | 100 | should_receive = 0 101 | can_receive = 0 102 | if self.last_hop_channel: 103 | should_receive = self.get_rebalance_amount(self.last_hop_channel) 104 | can_receive = self.get_amount_can_receive(self.last_hop_channel) 105 | 106 | if can_receive < 0: 107 | to_alias = self.lnd.get_node_alias(self.last_hop_channel.remote_pubkey) 108 | print( 109 | f"Error: target channel {format_channel_id(self.last_hop_channel.chan_id)} to " 110 | f"{format_alias(to_alias)} needs to {chalk.green('send')} funds to be within bounds, " 111 | f"you want it to {chalk.red('receive')} funds." 112 | f" Specify amount manually if this was intended." 113 | ) 114 | return 0 115 | 116 | if self.first_hop_channel and self.last_hop_channel: 117 | computed_amount = max(min(can_receive, should_send), min(can_send, should_receive)) 118 | elif self.first_hop_channel: 119 | computed_amount = should_send 120 | else: 121 | computed_amount = should_receive 122 | 123 | computed_amount = int(computed_amount) 124 | if amount is not None: 125 | if computed_amount >= 0: 126 | computed_amount = min(amount, computed_amount) 127 | else: 128 | computed_amount = max(-amount, computed_amount) 129 | return computed_amount 130 | 131 | def get_amount_can_send(self, channel): 132 | return get_local_available(channel) - self.get_scaled_min_local(channel) 133 | 134 | def get_amount_can_receive(self, channel): 135 | return get_remote_available(channel) - self.get_scaled_min_remote(channel) 136 | 137 | def get_channel_for_channel_id(self, channel_id): 138 | if not channel_id: 139 | return None 140 | for channel in self.lnd.get_channels(): 141 | if channel.chan_id == channel_id: 142 | return channel 143 | raise Exception(f"Unable to find channel with ID {channel_id}") 144 | 145 | def get_private_channels(self): 146 | return self.lnd.get_channels(active_only=True,private_only=True), 147 | 148 | def list_channels(self, reverse=False): 149 | sorted_channels = sorted( 150 | self.lnd.get_channels(active_only=True), 151 | key=lambda c: self.get_sort_key(c), 152 | reverse=reverse 153 | ) 154 | for channel in sorted_channels: 155 | self.show_channel(channel, reverse) 156 | 157 | def show_channel(self, channel, reverse=False): 158 | rebalance_amount = self.get_rebalance_amount(channel) 159 | if not self.arguments.show_all and not self.arguments.show_only: 160 | if rebalance_amount < 0 and not reverse: 161 | return 162 | if rebalance_amount > 0 and reverse: 163 | return 164 | if abs(rebalance_amount) < self.min_amount: 165 | return 166 | rebalance_amount_formatted = f"{rebalance_amount:10,}" 167 | own_ppm = self.lnd.get_ppm_to(channel.chan_id) 168 | remote_ppm = self.lnd.get_ppm_from(channel.chan_id) 169 | print(f"Channel ID: {format_channel_id(channel.chan_id)}") 170 | print(f"Alias: {format_alias(self.lnd.get_node_alias(channel.remote_pubkey))}") 171 | print(f"Pubkey: {format_boring_string(channel.remote_pubkey)}") 172 | print(f"Channel Point: {format_boring_string(channel.channel_point)}") 173 | print(f"Local ratio: {get_local_ratio(channel):.3f}") 174 | print(f"Fee rates: {format_ppm(own_ppm)} (own), {format_ppm(remote_ppm)} (peer)") 175 | print(f"Capacity: {channel.capacity:10,}") 176 | print(f"Remote available: {format_amount(get_remote_available(channel), 10)}") 177 | print(f"Local available: {format_amount_green(get_local_available(channel), 10)}") 178 | print(f"Rebalance amount: {rebalance_amount_formatted}") 179 | print(get_capacity_and_ratio_bar(channel, self.lnd.get_max_channel_capacity())) 180 | print("") 181 | 182 | def list_channels_compact(self): 183 | candidates = sorted( 184 | self.lnd.get_channels(active_only=True), 185 | key=lambda c: self.get_sort_key(c), 186 | reverse=False 187 | ) 188 | for candidate in candidates: 189 | id_formatted = format_channel_id(candidate.chan_id) 190 | local_formatted = format_amount_green(get_local_available(candidate), 11) 191 | remote_formatted = format_amount(get_remote_available(candidate), 11) 192 | alias_formatted = format_alias(self.lnd.get_node_alias(candidate.remote_pubkey)) 193 | print(f"{id_formatted} | {local_formatted} | {remote_formatted} | {alias_formatted}") 194 | 195 | def start(self): 196 | if self.arguments.list_candidates and self.arguments.show_only: 197 | channel_id = self.parse_channel_id(self.arguments.show_only) 198 | channel = self.get_channel_for_channel_id(channel_id) 199 | self.show_channel(channel) 200 | sys.exit(0) 201 | 202 | if self.arguments.listcompact: 203 | self.list_channels_compact() 204 | sys.exit(0) 205 | 206 | if self.arguments.list_candidates: 207 | incoming = self.arguments.incoming is None or self.arguments.incoming 208 | if incoming: 209 | self.list_channels(reverse=False) 210 | else: 211 | self.list_channels(reverse=True) 212 | sys.exit(0) 213 | 214 | if self.first_hop_channel_id == -1: 215 | self.first_hop_channel = random.choice(self.get_first_hop_candidates()) 216 | else: 217 | self.first_hop_channel = self.get_channel_for_channel_id(self.first_hop_channel_id) 218 | 219 | if self.last_hop_channel_id == -1: 220 | self.last_hop_channel = random.choice(self.get_last_hop_candidates()) 221 | else: 222 | self.last_hop_channel = self.get_channel_for_channel_id(self.last_hop_channel_id) 223 | 224 | amount = self.get_amount() 225 | if self.arguments.percentage: 226 | new_amount = int(round(amount * self.arguments.percentage / 100)) 227 | print(f"Using {self.arguments.percentage}% of amount {format_amount(amount)}: {format_amount(new_amount)}") 228 | amount = new_amount 229 | 230 | if amount == 0: 231 | print(f"Amount is {format_amount(0)} sat, nothing to do") 232 | sys.exit(1) 233 | 234 | if amount < self.min_amount: 235 | print(f"Amount {format_amount(amount)} sat is below limit of {format_amount(self.min_amount)} sat, " 236 | f"nothing to do (see --min-amount)") 237 | sys.exit(1) 238 | 239 | if self.arguments.reckless: 240 | self.output.print_line(format_error("Reckless mode enabled!")) 241 | 242 | if self.arguments.ignore_missed_fee: 243 | self.output.print_line(format_error("Ignoring missed fee!")) 244 | 245 | fee_factor = self.arguments.fee_factor 246 | fee_limit_sat = self.arguments.fee_limit 247 | fee_ppm_limit = self.arguments.fee_ppm_limit 248 | excluded = [] 249 | if self.arguments.exclude: 250 | for chan_id in self.arguments.exclude: 251 | excluded.append(self.parse_channel_id(chan_id)) 252 | if self.arguments.exclude_private: 253 | private_channels = self.get_private_channels(self) 254 | for channel in private_channels: 255 | excluded.append(self.parse_channel_id(channel.chan_id)) 256 | return Logic( 257 | self.lnd, 258 | self.first_hop_channel, 259 | self.last_hop_channel, 260 | amount, 261 | excluded, 262 | fee_factor, 263 | fee_limit_sat, 264 | fee_ppm_limit, 265 | self.min_local, 266 | self.min_remote, 267 | self.output, 268 | self.arguments.reckless, 269 | self.arguments.ignore_missed_fee 270 | ).rebalance() 271 | 272 | def get_first_hop_candidates(self): 273 | result = [] 274 | for channel in self.lnd.get_channels(active_only=True, public_only=self.arguments.exclude_private): 275 | if self.get_rebalance_amount(channel) < 0: 276 | result.append(channel) 277 | return result 278 | 279 | def get_last_hop_candidates(self): 280 | result = [] 281 | for channel in self.lnd.get_channels(active_only=True): 282 | if self.get_rebalance_amount(channel) > 0: 283 | result.append(channel) 284 | return result 285 | 286 | 287 | def main(): 288 | argument_parser = get_argument_parser() 289 | arguments = argument_parser.parse_args() 290 | if arguments.incoming is not None and not arguments.list_candidates: 291 | print( 292 | "--outgoing and --incoming only work in conjunction with --list-candidates" 293 | ) 294 | sys.exit(1) 295 | 296 | if arguments.percentage: 297 | if arguments.percentage < 1 or arguments.percentage > 100: 298 | print("--percentage must be between 1 and 100") 299 | argument_parser.print_help() 300 | sys.exit(1) 301 | 302 | if arguments.reckless and not arguments.amount: 303 | print("You need to specify an amount for --reckless") 304 | argument_parser.print_help() 305 | sys.exit(1) 306 | 307 | if arguments.reckless and arguments.adjust_amount_to_limits: 308 | print("You must not use -A/--adjust-amount-to-limits in combination with --reckless") 309 | argument_parser.print_help() 310 | sys.exit(1) 311 | 312 | if arguments.reckless and not arguments.fee_limit and not arguments.fee_ppm_limit: 313 | print("You need to specify a fee limit (-fee-limit or --fee-ppm-limit) for --reckless") 314 | argument_parser.print_help() 315 | sys.exit(1) 316 | 317 | first_hop_channel_id = vars(arguments)["from"] 318 | last_hop_channel_id = arguments.to 319 | 320 | no_channel_id_given = not last_hop_channel_id and not first_hop_channel_id 321 | if not arguments.listcompact and not arguments.list_candidates and no_channel_id_given: 322 | argument_parser.print_help() 323 | sys.exit(1) 324 | 325 | return Rebalance(arguments).start() 326 | 327 | 328 | def get_argument_parser(): 329 | parser = argparse.ArgumentParser() 330 | parser.add_argument( 331 | "--lnddir", 332 | default="_DEFAULT_", 333 | dest="lnddir", 334 | help="(default ~/.lnd) lnd directory", 335 | ) 336 | parser.add_argument( 337 | "--network", 338 | default='mainnet', 339 | dest='network', 340 | help='(default mainnet) lnd network (mainnet, testnet, simnet, ...)' 341 | ) 342 | parser.add_argument( 343 | "--grpc", 344 | default="localhost:10009", 345 | dest="grpc", 346 | help="(default localhost:10009) lnd gRPC endpoint", 347 | ) 348 | list_group = parser.add_argument_group( 349 | "list candidates", "Show the unbalanced channels." 350 | ) 351 | list_group.add_argument( 352 | "-l", 353 | "--list-candidates", 354 | action="store_true", 355 | help="list candidate channels for rebalance", 356 | ) 357 | 358 | show_options = list_group.add_mutually_exclusive_group() 359 | show_options.add_argument( 360 | "--show-all", 361 | default=False, 362 | action="store_true", 363 | help="also show channels with zero rebalance amount", 364 | ) 365 | show_options.add_argument( 366 | "--show-only", 367 | type=str, 368 | metavar="CHANNEL", 369 | help="only show information about the given channel", 370 | ) 371 | show_options.add_argument( 372 | "-c", 373 | "--compact", 374 | action="store_true", 375 | dest="listcompact", 376 | help="Shows a compact list of all channels, one per line including ID, inbound/outbound liquidity, and alias", 377 | ) 378 | 379 | direction_group = list_group.add_mutually_exclusive_group() 380 | direction_group.add_argument( 381 | "-o", 382 | "--outgoing", 383 | action="store_const", 384 | const=False, 385 | dest="incoming", 386 | help="lists channels with less than 1,000,000 (--min-remote) satoshis inbound liquidity", 387 | ) 388 | direction_group.add_argument( 389 | "-i", 390 | "--incoming", 391 | action="store_const", 392 | const=True, 393 | dest="incoming", 394 | help="(default) lists channels with less than 1,000,000 (--min-local) satoshis outbound liquidity", 395 | ) 396 | 397 | rebalance_group = parser.add_argument_group( 398 | "rebalance", 399 | "Rebalance a channel. You need to specify at least" 400 | " the 'from' channel (-f) or the 'to' channel (-t).", 401 | ) 402 | rebalance_group.add_argument( 403 | "-f", 404 | "--from", 405 | metavar="CHANNEL", 406 | type=str, 407 | help="Channel ID of the outgoing channel (funds will be taken from this channel). " 408 | "You may also specify the ID using the colon notation (12345:12:1), or the x notation (12345x12x1). " 409 | "You may also use -1 to choose a random candidate.", 410 | ) 411 | rebalance_group.add_argument( 412 | "-t", 413 | "--to", 414 | metavar="CHANNEL", 415 | type=str, 416 | help="Channel ID of the incoming channel (funds will be sent to this channel). " 417 | "You may also specify the ID using the colon notation (12345:12:1), or the x notation (12345x12x1). " 418 | "You may also use -1 to choose a random candidate.", 419 | ) 420 | amount_group = rebalance_group.add_mutually_exclusive_group() 421 | rebalance_group.add_argument( 422 | "-A", 423 | "--adjust-amount-to-limits", 424 | action="store_true", 425 | help="If set, adjust the amount to the limits (--min-local and --min-remote). The script will exit if the " 426 | "adjusted amount is below the --min-amount threshold. As such, this switch can be used if you do NOT want " 427 | "to rebalance if the channel is within the limits.", 428 | ) 429 | amount_group.add_argument( 430 | "-a", 431 | "--amount", 432 | type=int, 433 | help="Amount of the rebalance, in satoshis. If not specified, " 434 | "the amount computed for a perfect rebalance will be used", 435 | ) 436 | amount_group.add_argument( 437 | "-p", 438 | "--percentage", 439 | type=int, 440 | help="Set the amount to a percentage of the computed amount. " 441 | "As an example, if this is set to 50, half of the computed amount will be used. " 442 | "See --amount.", 443 | ) 444 | rebalance_group.add_argument( 445 | "--min-amount", 446 | default=10_000, 447 | type=int, 448 | help="(Default: 10,000) If the given or computed rebalance amount is below this limit, nothing is done.", 449 | ) 450 | rebalance_group.add_argument( 451 | "--min-local", 452 | type=int, 453 | default=1_000_000, 454 | help="(Default: 1,000,000) Ensure that the channels have at least this amount as outbound liquidity." 455 | ) 456 | rebalance_group.add_argument( 457 | "--min-remote", 458 | type=int, 459 | default=1_000_000, 460 | help="(Default: 1,000,000) Ensure that the channels have at least this amount as inbound liquidity." 461 | ) 462 | rebalance_group.add_argument( 463 | "-e", 464 | "--exclude", 465 | type=str, 466 | action="append", 467 | help="Exclude the given channel. Can be used multiple times.", 468 | ) 469 | rebalance_group.add_argument( 470 | "--exclude-private", 471 | action="store_true", 472 | default=False, 473 | help="Exclude private channels. This won't affect channel ID used at --to and/or --from but will take effect if you used -1 to get a random channel.", 474 | ) 475 | rebalance_group.add_argument( 476 | "--reckless", 477 | action="store_true", 478 | default=False, 479 | help="Allow rebalance transactions that are not economically viable. " 480 | "You might also want to set --min-local 0 and --min-remote 0. " 481 | "If set, you also need to set --amount and either --fee-limit or --fee-ppm-limit, and you must not enable " 482 | "--adjust-amount-to-limits (-A)." 483 | ) 484 | rebalance_group.add_argument( 485 | "--fee-factor", 486 | default=1.0, 487 | type=float, 488 | help="(default: 1.0) Compare the costs against the expected " 489 | "income, scaled by this factor. As an example, with --fee-factor 1.5, " 490 | "routes that cost at most 150%% of the expected earnings are tried. Use values " 491 | "smaller than 1.0 to restrict routes to only consider those earning " 492 | "more/costing less. This factor is ignored with --reckless.", 493 | ) 494 | rebalance_group.add_argument( 495 | "--ignore-missed-fee", 496 | action="store_true", 497 | default=False, 498 | help="(default: False) Ignore missed fee from source channel.", 499 | ) 500 | fee_group = rebalance_group.add_mutually_exclusive_group() 501 | fee_group.add_argument( 502 | "--fee-limit", 503 | type=int, 504 | help="If set, only consider rebalance transactions that cost up to the given number of satoshis. Note that " 505 | "the first hop costs are considered, even though you don't have to pay them." 506 | ) 507 | fee_group.add_argument( 508 | "--fee-ppm-limit", 509 | type=int, 510 | help="If set, only consider rebalance transactions that cost up to the given number of satoshis per " 511 | "1M satoshis sent. Note that the first hop costs are considered, even though you don't have to pay them." 512 | ) 513 | return parser 514 | 515 | 516 | def get_local_available(channel): 517 | return max(0, channel.local_balance - channel.local_chan_reserve_sat) 518 | 519 | 520 | def get_remote_available(channel): 521 | return max(0, channel.remote_balance - channel.remote_chan_reserve_sat) 522 | 523 | 524 | def get_local_ratio(channel): 525 | remote = channel.remote_balance 526 | local = channel.local_balance 527 | return float(local) / (remote + local) 528 | 529 | 530 | def get_capacity_and_ratio_bar(candidate, max_channel_capacity): 531 | columns = get_columns() 532 | columns_scaled_to_capacity = int( 533 | round(columns * float(candidate.capacity) / max_channel_capacity) 534 | ) 535 | if candidate.capacity >= max_channel_capacity: 536 | columns_scaled_to_capacity = columns 537 | 538 | bar_width = columns_scaled_to_capacity - 2 539 | ratio = get_local_ratio(candidate) 540 | length = int(round(ratio * bar_width)) 541 | return print_bar(bar_width, length) 542 | 543 | 544 | def get_columns(): 545 | if platform.system() == "Linux" and sys.__stdin__.isatty(): 546 | return int(os.popen("stty size", "r").read().split()[1]) 547 | else: 548 | return 80 549 | 550 | 551 | success = main() 552 | if success: 553 | sys.exit(0) 554 | sys.exit(1) 555 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | googleapis-common-protos==1.69.2 2 | grpcio==1.70.0 3 | protobuf==3.20.2 4 | six==1.17.0 5 | yachalk==0.1.7 6 | -------------------------------------------------------------------------------- /routes.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | MAX_ROUTES_TO_REQUEST = 100 4 | 5 | 6 | class Routes: 7 | num_requested_routes = 0 8 | all_routes = [] 9 | returned_routes = [] 10 | ignored_pairs = [] 11 | ignored_nodes = [] 12 | 13 | def __init__( 14 | self, 15 | lnd, 16 | payment_request, 17 | first_hop_channel, 18 | last_hop_channel, 19 | fee_limit_msat, 20 | output 21 | ): 22 | self.lnd = lnd 23 | self.payment_request = payment_request 24 | self.first_hop_channel = first_hop_channel 25 | self.last_hop_channel = last_hop_channel 26 | self.fee_limit_msat = fee_limit_msat 27 | self.output = output 28 | 29 | def has_next(self): 30 | self.update_routes() 31 | return self.returned_routes < self.all_routes 32 | 33 | def get_next(self): 34 | self.update_routes() 35 | for route in self.all_routes: 36 | if route not in self.returned_routes: 37 | self.returned_routes.append(route) 38 | return route 39 | return None 40 | 41 | def update_routes(self): 42 | while True: 43 | if self.returned_routes < self.all_routes: 44 | return 45 | if self.num_requested_routes >= MAX_ROUTES_TO_REQUEST: 46 | return 47 | self.request_route() 48 | 49 | def request_route(self): 50 | amount = self.get_amount() 51 | if self.last_hop_channel: 52 | last_hop_pubkey = self.last_hop_channel.remote_pubkey 53 | else: 54 | last_hop_pubkey = None 55 | if self.first_hop_channel: 56 | first_hop_channel_id = self.first_hop_channel.chan_id 57 | else: 58 | first_hop_channel_id = None 59 | routes = self.lnd.get_route( 60 | last_hop_pubkey, 61 | amount, 62 | self.ignored_pairs, 63 | self.ignored_nodes, 64 | first_hop_channel_id, 65 | self.fee_limit_msat, 66 | ) 67 | if routes is None: 68 | self.num_requested_routes = MAX_ROUTES_TO_REQUEST 69 | else: 70 | self.num_requested_routes += 1 71 | for route in routes: 72 | self.add_route(route) 73 | 74 | def add_route(self, route): 75 | if route is None: 76 | return 77 | if route not in self.all_routes: 78 | self.all_routes.append(route) 79 | 80 | def get_amount(self): 81 | return self.payment_request.num_satoshis 82 | 83 | def ignore_first_hop(self, channel, show_message=True): 84 | own_key = self.lnd.get_own_pubkey() 85 | other_key = channel.remote_pubkey 86 | self.ignore_edge_from_to(channel.chan_id, own_key, other_key, show_message) 87 | 88 | def ignore_edge_on_route(self, failure_source_pubkey, route): 89 | ignore_next = False 90 | for hop in route.hops: 91 | if ignore_next: 92 | self.ignore_edge_from_to( 93 | hop.chan_id, failure_source_pubkey, hop.pub_key 94 | ) 95 | return 96 | if hop.pub_key == failure_source_pubkey: 97 | ignore_next = True 98 | 99 | def ignore_hop_on_route(self, hop_to_ignore, route): 100 | previous_pubkey = self.lnd.get_own_pubkey() 101 | for hop in route.hops: 102 | if hop == hop_to_ignore: 103 | self.ignore_edge_from_to(hop.chan_id, previous_pubkey, hop.pub_key) 104 | return 105 | previous_pubkey = hop.pub_key 106 | 107 | def ignore_high_fee_hops(self, route): 108 | ignore = [] 109 | max_fee_msat = -1 110 | max_fee_hop = None 111 | for hop in route.hops: 112 | if route.hops[-2].chan_id == hop.chan_id and self.last_hop_channel: 113 | continue 114 | if hop.fee_msat > max_fee_msat: 115 | max_fee_msat = hop.fee_msat 116 | max_fee_hop = hop 117 | 118 | if max_fee_hop: 119 | hops = list(route.hops) 120 | first_hop = hops[0] 121 | first_hop_fee_rate = self.lnd.get_ppm_to(first_hop.chan_id) 122 | missed_fee_first_hop_msat = first_hop.amt_to_forward_msat / 1_000_000 * first_hop_fee_rate 123 | if missed_fee_first_hop_msat > max_fee_msat and not self.first_hop_channel: 124 | ignore.append(first_hop) 125 | else: 126 | hop_to_ignore = hops[hops.index(max_fee_hop) + 1] 127 | ignore.append(hop_to_ignore) 128 | for hop in ignore: 129 | self.ignore_hop_on_route(hop, route) 130 | 131 | def ignore_channel(self, chan_id): 132 | try: 133 | edge = self.lnd.get_edge(chan_id) 134 | except Exception: 135 | return 136 | self.ignore_edge_from_to(chan_id, edge.node1_pub, edge.node2_pub) 137 | self.ignore_edge_from_to(chan_id, edge.node2_pub, edge.node1_pub) 138 | 139 | def ignore_edge_from_to(self, chan_id, from_pubkey, to_pubkey, show_message=True): 140 | pair = { 141 | "from": base64.b16decode(from_pubkey, True), 142 | "to": base64.b16decode(to_pubkey, True), 143 | } 144 | if pair in self.ignored_pairs: 145 | return 146 | if show_message: 147 | self.output.print_line( 148 | f"Ignoring {self.output.get_channel_representation(chan_id, to_pubkey, from_pubkey)}") 149 | self.ignored_pairs.append(pair) 150 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from setuptools import setup 3 | 4 | setup( 5 | name='rebalance-lnd', 6 | version='2.3', 7 | description='A script that can be used to balance lightning channels of a LND node', 8 | author='Carsten Otto', 9 | author_email='bitcoin@c-otto.de', 10 | classifiers=[ 11 | 'Development Status :: 5 - Production/Stable', 12 | 'Environment :: Console', 13 | 'Intended Audience :: System Administrators', 14 | 'License :: OSI Approved :: MIT License', 15 | 'Operating System :: OS Independent', 16 | 'Programming Language :: Python :: 3' 17 | ], 18 | keywords='lightning, lnd, bitcoin', 19 | python_requires='>=3.6, <4', 20 | entry_points={ 21 | 'console_scripts' : ['rebalance-lnd=rebalance:main'] 22 | }, 23 | project_urls={ 24 | 'Source' : 'https://github.com/C-Otto/rebalance-lnd' 25 | } 26 | ) 27 | -------------------------------------------------------------------------------- /test.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-alpine3.14 2 | 3 | ENV PIP_NO_CACHE_DIR=off \ 4 | PIP_DISABLE_PIP_VERSION_CHECK=on 5 | 6 | COPY requirements.txt ./ 7 | 8 | # System deps: 9 | RUN apk add --update --no-cache \ 10 | linux-headers \ 11 | gcc \ 12 | g++ \ 13 | git openssh-client \ 14 | && apk add libstdc++ --no-cache --repository http://dl-3.alpinelinux.org/alpine/edge/testing/ --allow-untrusted \ 15 | # Install python packages 16 | && pip install -r requirements.txt \ 17 | # Remove system deps 18 | && apk del linux-headers gcc g++ git openssh-client 19 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # Running tests 2 | 1. To start the test environment container run: 3 | ```shell 4 | make up 5 | ``` 6 | 2. Run tests 7 | ```shell 8 | make test 9 | ``` 10 | 3. Bring down the docker compose environment 11 | ```shell 12 | make down 13 | ``` 14 | 15 | If you need to shell into the docker container to run extra commands you can do so by running 16 | ```shell 17 | make shell 18 | ``` 19 | and then your command, e,g., 20 | ```shell 21 | pip install black 22 | ``` 23 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/C-Otto/rebalance-lnd/9120c93e48cfdf99e7e459309da2358c4b774bd2/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/test_output.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from output import ( 3 | format_alias, 4 | format_ppm, 5 | format_fee_msat, 6 | format_fee_msat_red, 7 | format_fee_msat_white, 8 | format_fee_sat, 9 | format_earning, 10 | format_amount, 11 | format_amount_green, 12 | format_error, 13 | print_bar, 14 | format_boring_string, 15 | format_success, 16 | format_channel_id, 17 | format_warning, 18 | ) 19 | 20 | 21 | class TestFormat(unittest.TestCase): 22 | def test_format_alias(self): 23 | """Verifies format output""" 24 | test_aliases = [ 25 | ("utf-node", "\x1b[1mutf-node\x1b[22m"), 26 | ("node", "\x1b[1mnode\x1b[22m"), 27 | ("my-node", "\x1b[1mmy-node\x1b[22m"), 28 | (1, "\x1b[1m1\x1b[22m"), 29 | ("1", "\x1b[1m1\x1b[22m"), 30 | (b"1", "\x1b[1mb'1'\x1b[22m"), 31 | (True, "\x1b[1mTrue\x1b[22m"), 32 | ("True", "\x1b[1mTrue\x1b[22m"), 33 | (False, "\x1b[1mFalse\x1b[22m"), 34 | ("False", "\x1b[1mFalse\x1b[22m"), 35 | (None, "\x1b[1mNone\x1b[22m"), 36 | ("None", "\x1b[1mNone\x1b[22m"), 37 | ] 38 | 39 | for test_alias in test_aliases: 40 | self.assertEqual(format_alias(test_alias[0]), test_alias[1]) 41 | 42 | def test_format_ppm(self): 43 | """Verifies format output""" 44 | test_cases = [ 45 | (1000, None, "\x1b[1m1,000ppm\x1b[22m"), 46 | (1000, 1, "\x1b[1m1,000ppm\x1b[22m"), 47 | (1000, 10, "\x1b[1m 1,000ppm\x1b[22m"), 48 | ] 49 | 50 | for test_case in test_cases: 51 | ppm = test_case[0] 52 | min_length = test_case[1] 53 | output = test_case[2] 54 | 55 | self.assertEqual(format_ppm(ppm, min_length), output) 56 | 57 | def test_invalid_format_ppm(self): 58 | """Observes behavior with string input""" 59 | 60 | with self.assertRaises(ValueError) as context: 61 | ppm = "1000" 62 | min_length = None 63 | format_ppm(ppm, min_length) 64 | 65 | self.assertEqual(str(context.exception), "Cannot specify ',' with 's'.") 66 | 67 | def test_fee_msat(self): 68 | """Verifies format output""" 69 | test_cases = [ 70 | (1000, None, "\x1b[36m1,000 mSAT\x1b[39m"), 71 | (1000, 1, "\x1b[36m1,000 mSAT\x1b[39m"), 72 | (1000, 10, "\x1b[36m 1,000 mSAT\x1b[39m"), 73 | ] 74 | 75 | for test_case in test_cases: 76 | ppm = test_case[0] 77 | min_length = test_case[1] 78 | output = test_case[2] 79 | 80 | self.assertEqual(format_fee_msat(ppm, min_length), output) 81 | 82 | def test_invalid_format_fee_msat(self): 83 | """Observes behavior with string input""" 84 | 85 | with self.assertRaises(ValueError) as context: 86 | ppm = "1000" 87 | min_length = None 88 | format_fee_msat(ppm, min_length) 89 | 90 | self.assertEqual(str(context.exception), "Cannot specify ',' with 's'.") 91 | 92 | def test_fee_msat_red(self): 93 | """Verifies format output""" 94 | test_cases = [ 95 | (1000, None, "\x1b[31m1,000 mSAT\x1b[39m"), 96 | (1000, 1, "\x1b[31m1,000 mSAT\x1b[39m"), 97 | (1000, 10, "\x1b[31m 1,000 mSAT\x1b[39m"), 98 | ] 99 | 100 | for test_case in test_cases: 101 | ppm = test_case[0] 102 | min_length = test_case[1] 103 | output = test_case[2] 104 | 105 | self.assertEqual(format_fee_msat_red(ppm, min_length), output) 106 | 107 | def test_fee_msat_white(self): 108 | """Verifies format output""" 109 | test_cases = [ 110 | (1000, None, "\x1b[97m1,000 mSAT\x1b[39m"), 111 | (1000, 1, "\x1b[97m1,000 mSAT\x1b[39m"), 112 | (1000, 10, "\x1b[97m 1,000 mSAT\x1b[39m"), 113 | ] 114 | 115 | for test_case in test_cases: 116 | ppm = test_case[0] 117 | min_length = test_case[1] 118 | output = test_case[2] 119 | 120 | self.assertEqual(format_fee_msat_white(ppm, min_length), output) 121 | 122 | def test_fee_sat(self): 123 | """Verifies format output""" 124 | test_cases = [ 125 | (1000, "\x1b[36m1,000 sats\x1b[39m"), 126 | (1, "\x1b[36m1 sats\x1b[39m"), 127 | ] 128 | 129 | for test_case in test_cases: 130 | fee_sat = test_case[0] 131 | output = test_case[1] 132 | 133 | self.assertEqual(format_fee_sat(fee_sat), output) 134 | 135 | def test_format_earning(self): 136 | """Verifies format output""" 137 | test_cases = [ 138 | (1000, None, "\x1b[32m1,000 mSAT\x1b[39m"), 139 | (1000, 1, "\x1b[32m1,000 mSAT\x1b[39m"), 140 | (1000, 10, "\x1b[32m 1,000 mSAT\x1b[39m"), 141 | ] 142 | 143 | for test_case in test_cases: 144 | msat = test_case[0] 145 | min_width = test_case[1] 146 | output = test_case[2] 147 | 148 | self.assertEqual(format_earning(msat, min_width), output) 149 | 150 | def test_format_amount(self): 151 | """Verifies format output""" 152 | test_cases = [ 153 | (1000, None, "\x1b[33m1,000\x1b[39m"), 154 | (1000, 1, "\x1b[33m1,000\x1b[39m"), 155 | (1000, 10, "\x1b[33m 1,000\x1b[39m"), 156 | ] 157 | 158 | for test_case in test_cases: 159 | amount = test_case[0] 160 | min_width = test_case[1] 161 | output = test_case[2] 162 | 163 | self.assertEqual(format_amount(amount, min_width), output) 164 | 165 | def test_format_amount_green(self): 166 | """Verifies format output""" 167 | test_cases = [ 168 | (1000, 1, "\x1b[32m1,000\x1b[39m"), 169 | (1000, 10, "\x1b[32m 1,000\x1b[39m"), 170 | ] 171 | 172 | for test_case in test_cases: 173 | amount = test_case[0] 174 | min_width = test_case[1] 175 | output = test_case[2] 176 | 177 | self.assertEqual(format_amount_green(amount, min_width), output) 178 | 179 | def test_invalid_format_amount_green(self): 180 | """Observes behavior with invalid min_width""" 181 | 182 | with self.assertRaises(ValueError) as context: 183 | amount = "1000" 184 | min_width = None 185 | format_amount_green(amount, min_width) 186 | 187 | self.assertEqual(str(context.exception), "Invalid format specifier") 188 | 189 | def test_format_boring_string(self): 190 | """Verifies format output""" 191 | test_cases = [ 192 | ("hello", "\x1b[40m\x1b[90mhello\x1b[39m\x1b[49m"), 193 | ("world", "\x1b[40m\x1b[90mworld\x1b[39m\x1b[49m"), 194 | (True, "\x1b[40m\x1b[90mTrue\x1b[39m\x1b[49m"), 195 | (None, "\x1b[40m\x1b[90mNone\x1b[39m\x1b[49m"), 196 | ] 197 | 198 | for test_case in test_cases: 199 | string = test_case[0] 200 | output = test_case[1] 201 | 202 | self.assertEqual(format_boring_string(string), output) 203 | 204 | def test_format_channel_id(self): 205 | """Verifies format output""" 206 | test_cases = [ 207 | ("hello", "\x1b[40m\x1b[90mhello\x1b[39m\x1b[49m"), 208 | ("world", "\x1b[40m\x1b[90mworld\x1b[39m\x1b[49m"), 209 | (True, "\x1b[40m\x1b[90mTrue\x1b[39m\x1b[49m"), 210 | (None, "\x1b[40m\x1b[90mNone\x1b[39m\x1b[49m"), 211 | ] 212 | 213 | for test_case in test_cases: 214 | string = test_case[0] 215 | output = test_case[1] 216 | 217 | self.assertEqual(format_channel_id(string), output) 218 | 219 | def test_format_success(self): 220 | """Verifies format output""" 221 | test_cases = [ 222 | ("hello", "\x1b[46m\x1b[97mhello\x1b[39m\x1b[49m"), 223 | ("world", "\x1b[46m\x1b[97mworld\x1b[39m\x1b[49m"), 224 | (True, "\x1b[46m\x1b[97mTrue\x1b[39m\x1b[49m"), 225 | (None, "\x1b[46m\x1b[97mNone\x1b[39m\x1b[49m"), 226 | ] 227 | 228 | for test_case in test_cases: 229 | string = test_case[0] 230 | output = test_case[1] 231 | 232 | self.assertEqual(format_success(string), output) 233 | 234 | def test_format_warning(self): 235 | """Verifies format output""" 236 | test_cases = [ 237 | ("hello", "\x1b[33mhello\x1b[39m"), 238 | ("world", "\x1b[33mworld\x1b[39m"), 239 | (True, "\x1b[33mTrue\x1b[39m"), 240 | (None, "\x1b[33mNone\x1b[39m"), 241 | ] 242 | 243 | for test_case in test_cases: 244 | string = test_case[0] 245 | output = test_case[1] 246 | 247 | self.assertEqual(format_warning(string), output) 248 | 249 | def test_format_error(self): 250 | """Verifies format output""" 251 | test_cases = [ 252 | ("hello", "\x1b[31mhello\x1b[39m"), 253 | ("world", "\x1b[31mworld\x1b[39m"), 254 | (True, "\x1b[31mTrue\x1b[39m"), 255 | (None, "\x1b[31mNone\x1b[39m"), 256 | ] 257 | 258 | for test_case in test_cases: 259 | string = test_case[0] 260 | output = test_case[1] 261 | 262 | self.assertEqual(format_error(string), output) 263 | 264 | def test_print_bar(self): 265 | """Verifies format output""" 266 | test_cases = [ 267 | (1, 2, "\x1b[1m[\x1b[22m\x1b[1m█\x1b[22m\x1b[1m█\x1b[22m\x1b[1m]\x1b[22m"), 268 | ( 269 | 1, 270 | 10, 271 | "\x1b[1m[\x1b[22m\x1b[1m█\x1b[22m\x1b[1m█\x1b[22m\x1b[1m█\x1b[22m\x1b[1m█\x1b[22m\x1b[1m█\x1b[22m\x1b[1m█\x1b[22m\x1b[1m█\x1b[22m\x1b[1m█\x1b[22m\x1b[1m█\x1b[22m\x1b[1m█\x1b[22m\x1b[1m]\x1b[22m", 272 | ), 273 | (10, 1, "\x1b[1m[\x1b[22m\x1b[1m█\x1b[22m░░░░░░░░░\x1b[1m]\x1b[22m"), 274 | ] 275 | 276 | for test_case in test_cases: 277 | width = test_case[0] 278 | length = test_case[1] 279 | output = test_case[2] 280 | self.assertEqual(print_bar(width, length), output) 281 | --------------------------------------------------------------------------------