diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cf47566 --- /dev/null +++ b/.gitignore @@ -0,0 +1,169 @@ +phoenixd-lnurl.env + +# Ruff formatter +.ruff-cache/ + +# From: https://github.com/github/gitignore/blob/4488915eec0b3a45b5c63ead28f286819c0917de/Python.gitignore +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff +.ruff-cache/ diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..69e5cf7 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +python 3.12.2 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..22e8532 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.12-alpine + +WORKDIR /var/phoenixd_lnurl + +COPY ./requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY ./app ./app +COPY phoenixd-lnurl.env . + +CMD ["uvicorn", "app.main:app_factory", "--factory", "--proxy-headers", "--host", "0.0.0.0", "--port", "8000"] diff --git a/LICENSE.Apache-2 b/LICENSE.Apache-2 new file mode 100644 index 0000000..d4b0d83 --- /dev/null +++ b/LICENSE.Apache-2 @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 Angus Pearson + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSE.BSD-2-Clause b/LICENSE.BSD-2-Clause new file mode 100644 index 0000000..3b61d21 --- /dev/null +++ b/LICENSE.BSD-2-Clause @@ -0,0 +1,24 @@ +BSD 2-Clause License + +Copyright (c) 2024, Angus Pearson + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e450a5e --- /dev/null +++ b/README.md @@ -0,0 +1,232 @@ +# LNURL for phoenixd ⚡️ + +**🚧 NOTE This is new software, loss of funds and other mishaps are likely 🚧** + +A simple wrapper for [ACINQ/phoenixd](https://github.com/ACINQ/phoenixd) that supports basic [LNURL](https://github.com/lnurl/luds) so you can self-host your lightning address with near-minimum effort 💯. + +Supports **one** user with a human-readable LNURL like `lightning:satoshi@gmx.com`, as well as LNURL LUD-06 (the long Bech encoded `lightning:LNURL1blahblah` kind) *and* a snazzy [tip webpage at `/lnurl`](https://1f52b.xyz/lnurl): + +![Tip page screenshots (web and phone)](./img/screenshot.jpg) + +The idea is that you can run your own `phoenixd` instance and use it to receive Lightning tips, Zaps on Nostr and more-generally small usually un-requested payments. +This is intended for a single person to use, because they like self-hosting and owning their own stuff. + +If you're looking for something more complex, like an eCommerce Lightning solution, this is almost certainly going to be too simple for you; +check out [LNBits](https://lnbits.com/) or [BTCPay Server](https://btcpayserver.org/) and other things like those. +Note that LNBits will support phoenixd [soon™️](https://github.com/lnbits/lnbits/pull/2362). + + +## Compatibility + +Developed against `phoenixd version 0.1.3-d805f81`; also note that phoenixd is also new software and future releases may break things. + +Currently tested on MacOS and Linux; YMMV on other UNIXes, and on Windows. + + +### Supported LNURL LUDs: + + * [LUD-01](https://github.com/lnurl/luds/blob/luds/01.md): Base LNURL encoding and decoding + * [LUD-06](https://github.com/lnurl/luds/blob/luds/06.md): `payRequest` base spec. + * [LUD-16](https://github.com/lnurl/luds/blob/luds/16.md): Paying to static internet identifiers *(email-like addresses)*. + + +## Install + +*Note: Docker can be used to run this instead* + +If you haven't got it already, install [phoenixd](https://github.com/ACINQ/phoenixd/releases) so you have `phoenixd` and `phoenix-cli` in your path. + +See `.tool-versions` for the currently used version of Python. +We're also using `pip-tools` to manage dependencies. + +```shell +# Strongly recommend you create a python environment first: +python -m venv env +. env/bin/activate + +# manually install pip-tools: +pip install pip-tools + +# then sync the dependencies: +pip-sync +``` + +## Setup + +Using this example `~/.phoenix/phoenix.conf` for demonstration purposes: + +```conf +chain=testnet +http-password=hunter2 +http-bind-port=9740 +auto-liquidity=2m +max-absolute-fee=100000 +``` + +For **production** use, you can *just* install and run `phoenixd` for the first time; +it will create `~/.phoenix` with sane defaults and an auto-generated http password. + +You'll then need to configure `phoenixd-lnurl` itself. Copy `phoenixd-lnurl.env.example` to `phoenixd-lnurl.env` and edit it with the values you want; info on each option is given in the template. + +Importantly: + + 1. `PHOENIXD_URL` needs to set so that this app can talk to your phoenixd. + * Note that the `http-password` from phoenixd's config has to be in this URL + 2. `LNURL_HOSTNAME` must be the public domain you're serving from. You need to have HTTPS set up for LNURL to work. + +*Finally*, you're ready to go! + +```shell +# start the phoenixd-lnurl server: +./run.sh +``` + +**Example Nginx config** + +This Nginx config snippet will pass only the paths **phoenixd-lnurl** needs to work to the application: + +``` +server { + # ... + + location /lnurl { + proxy_pass http://localhost:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + } + location /lnurlp { + proxy_pass http://localhost:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + } + location /.well-known/lnurlp { + proxy_pass http://localhost:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + } + + # ... +} +``` + +(May also hit selinux, try `setsebool -P httpd_can_network_connect true`) + + +**Example SystemD Unit** + +*Note* this assumes you have installed to `/var/www/phoenixd-lnurl`. +You will also need to change `User` and `Group`. + +```conf +[Unit] +Description=phoenixd-lnurlp +After=network.target + +[Service] +User= +Group= +WorkingDirectory=/var/www/phoenixd-lnurl/src +Environment="PATH=/var/www/phoenixd-lnurl/env/bin:/usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin" +ExecStart=/var/www/phoenixd-lnurl/run.sh + +[Install] +WantedBy=multi-user.target +``` + + +## Docker + +(*Follow the **Setup** steps to configure phoenixd-lnurl first*) + +```shell +docker build . -t phoenixd-lnurl + +# ⚠️ This container will need to be able to connect to your `phoenixd` instance. +# To do this you might need to fiddle with the config and/or docker networking. +docker run -p 8000:8000 -it phoenixd-lnurl +``` + + +--- + +## Developing + +There's a set of additional dev requirements you need to install for tests to work and stuff: + +Life will be easier if you also have [`just`](https://github.com/casey/just) installed, but you can get by without it. + +```shell +just install +# Or, manually: +# $ pip-sync requirements-dev.txt + +# Run just just to see other options +just + +# API docs (paths for phoenixd-lnurl with try-it-out buttons): +just docs +``` + +If you are stubborn, you can also forego installing `pip-tools` and use a regular `pip install -r requirements-dev.txt`, but changes to requirements must be made using the pip-tools tooling. + +Using a tool like [`ngrok`](https://ngrok.com/) to proxy your local server (and optionally phoenixd) to the internet is handy, as LNURL requires `https` for clearnet. + +Getting a decent testnet Lightning wallet with all the bells and whistles is also a bit of a pain. +I found [Zeus](https://zeusln.com/) worked well using the *Embedded LND node* on *testnet* without much fuss -- caveat being that you can't *also* have a mainnet embedded LND configured. +Running a second phoenixd would also work, but it doesn't support LNURL so you'd have to copy-paste invoices and manually call phoenixd-lnurl. + +Once you have that, you'll have to hunt for a testnet faucet to get some testnet sats. + +When ready: + +```shell +# Make sure you've already got `phoenixd` running! +just serve +``` + + +### Roadmap to v1.0 + +- [X] Just receive LNURL LUD-16 payments (zaps) +- [X] Simple "zap me" QR code and copyable `lightning:LNURL1...` link webpage LUD-16 +- [X] Provide sample Docker image +- [X] Provide sample Nginx config +- [X] Provide sample Systemd service definition +- [ ] Basic CI (check normal install, dev install) +- [ ] Maybe also provide sample Traefik config +- [ ] Support configurable URL prefix for the app for people that might have collisions (or do this in nginx conf) +- [ ] Support `.onion` hosting (HTTPS is assumed in a few places), needed for self-hosting on things like Umbrel +- [ ] Support [LUD-18: Payer identity in `payRequest` protocol](https://github.com/lnurl/luds/blob/luds/18.md) + + +### Later Roadmap + +- [ ] Notify when payments are received (Nostr DM?) +- [ ] Support some kind of withdrawal mechanism (via Nostr DM?) instead of needing manual `phoenix-cli` use to get money out +- [ ] Support actual Nostr Zaps +- [ ] Also optionally be a Nostr NIP-05 server +- [ ] Support multiple usernames +- [ ] (maybe-scope-creep) auto-zap content you interact with/like on Nostr if funds are available + + +--- + +## Tips 😘 + +`1f52b@1f52b.xyz` (yes, I am dogfooding) or [tip page](https://1f52b.xyz/lnurl) + + +--- + +## License ⚖️ + +This work is dual-licensed under Apache 2.0 and BSD-2-Clause. +You can choose between one of them if you use this work. + +`SPDX-License-Identifier: Apache-2.0 OR BSD-2-Clause` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..0a023e0 --- /dev/null +++ b/app/main.py @@ -0,0 +1,386 @@ +import hashlib +import json +import math +import sys +from contextlib import asynccontextmanager +from typing import Annotated + +import aiohttp +import lnurl +import qrcode.image.svg +from fastapi import ( + APIRouter, + FastAPI, + Path, + Query, + status, +) +from fastapi.exceptions import RequestValidationError +from fastapi.middleware.cors import CORSMiddleware +from fastapi.requests import Request +from fastapi.responses import ( + JSONResponse, + Response, +) +from fastapi.templating import Jinja2Templates +from lnurl import ( + LnurlErrorResponse, + LnurlPayActionResponse, + LnurlPayResponse, +) +from lnurl.types import MilliSatoshi +from loguru import logger +from qrcode.main import QRCode +from starlette.exceptions import HTTPException as StarletteHTTPException + +from .phoenixd_client import ( + CreateInvoiceResponse, + PhoenixdHttpClient, + PhoenixdMockClient, +) +from .settings import PhoenixdLNURLSettings +from .setup_logging import intercept_logging + +DEFAULT_ERROR_RESPONSE_MODELS: dict[int | str, dict[str, type]] = { + 400: {"model": LnurlErrorResponse}, + 404: {"model": LnurlErrorResponse}, + 422: {"model": LnurlErrorResponse}, + 500: {"model": LnurlErrorResponse}, +} + +router = APIRouter() +templates = Jinja2Templates(directory="app/templates") + + +def metadata_for_payrequest(settings: PhoenixdLNURLSettings) -> str: + return json.dumps( + [ + ["text/plain", f"Zap {settings.username} some sats"], + ["text/identifier", settings.lnurl_address()], + # ["image/jpeg;base64", "TODO optional"], + ] + ) + + +@router.get( + path="/lnurl", + summary="Get a LUD-01 LNURL QR Code and LUD-16 identifier", + description="A Tip webpage, providing a way for someone to discover your LNURL address", + operation_id="lnurl-tip-page", + response_class=Response, +) +async def lnurl_get_lud01(request: Request) -> Response: + settings: PhoenixdLNURLSettings = request.app.state.settings + lnurl_address = settings.lnurl_address() + encoded = lnurl.encode(str(settings.base_url() / "lnurlp" / settings.username)) + lnurl_qr = QRCode( + image_factory=qrcode.image.svg.SvgPathFillImage, + box_size=15, + ) + lnurl_qr.add_data(encoded) + lnurl_qr.make(fit=True) + lnurl_qr_image = lnurl_qr.make_image() + return templates.TemplateResponse( + name="lnurl-splash.html", + context={ + "request": request, + "username": settings.username, + "lnurl_address": lnurl_address, + "nostr_address": settings.user_nostr_address, + "profile_image_url": settings.user_profile_image_url, + "meta_description": settings.lnurl_hostname, + "meta_author": lnurl_address, + "encoded_lnurl": encoded, + "lnurl_qr": lnurl_qr_image.to_string(encoding="unicode"), + "smaller_heading": settings.is_long_username(), + }, + ) + + +@router.get( + path="/lnurlp/{username}", + summary="payRequest LUD-06", + operation_id="lnurlp-LUD06", + response_model=LnurlPayResponse, + responses=DEFAULT_ERROR_RESPONSE_MODELS, + response_model_exclude_none=True, + response_model_exclude_unset=False, +) +async def lnurl_pay_request_lud06( + request: Request, + username: Annotated[ + str, + Path( + description="username to pay", + examples=["satoshi"], + regex=r"^[a-z0-9-_\.]+$", + ), + ], +) -> LnurlPayResponse | JSONResponse: + """ + Implements [LUD-06](https://github.com/lnurl/luds/blob/luds/06.md) + `payRequest` initial step + """ + settings: PhoenixdLNURLSettings = request.app.state.settings + if username != settings.username: + return JSONResponse( + status_code=status.HTTP_404_NOT_FOUND, + content=LnurlErrorResponse(reason="Unknown user").dict(), + ) + username = settings.username + + logger.info("LUD-06 payRequest for username='{username}'", username=username) + return LnurlPayResponse.parse_obj( + dict( + callback=str(settings.base_url() / f"lnurlp/{username}/callback"), + minSendable=settings.min_sats_receivable * 1000, + maxSendable=settings.max_sats_receivable * 1000, + metadata=metadata_for_payrequest(settings), + ) + ) + + +@router.get( + path="/.well-known/lnurlp/{username}", + summary="payRequest LUD-16", + operation_id="lnurlp-LUD16", + response_model=LnurlPayResponse, + responses=DEFAULT_ERROR_RESPONSE_MODELS, + response_model_exclude_none=True, + response_model_exclude_unset=False, +) +async def lnurl_pay_request_lud16( + request: Request, + username: Annotated[ + str, + Path( + description="username to pay", + examples=["satoshi"], + regex=r"^[a-z0-9-_\.]+$", + ), + ], +) -> LnurlPayResponse | JSONResponse: + """ + Implements [LUD-16](https://github.com/lnurl/luds/blob/luds/16.md) `payRequest` + initial step, using human-readable `username@host` addresses. + """ + settings: PhoenixdLNURLSettings = request.app.state.settings + if username != settings.username: + return JSONResponse( + status_code=status.HTTP_404_NOT_FOUND, + content=LnurlErrorResponse(reason="Unknown user").dict(), + ) + username = settings.username + + logger.info("LUD-16 payRequest for username='{username}'", username=username) + return LnurlPayResponse.parse_obj( + dict( + callback=str(settings.base_url() / f"lnurlp/{username}/callback"), + minSendable=settings.min_sats_receivable * 1000, + maxSendable=settings.max_sats_receivable * 1000, + metadata=metadata_for_payrequest(settings), + ) + ) + + +@router.get( + path="/lnurlp/{username}/callback", + summary="payRequest callback LUD-06", + operation_id="lnurlp-LUD06 callback", + response_model=LnurlPayActionResponse, + responses=DEFAULT_ERROR_RESPONSE_MODELS, + response_model_exclude_none=True, + response_model_exclude_unset=False, +) +async def lnurl_pay_request_callback_lud06( + request: Request, + username: Annotated[ + str, + Path( + description="username to pay", + examples=["satoshi"], + regex=r"^[a-z0-9-_\.]+$", + ), + ], + amount: Annotated[ + MilliSatoshi, + Query( + description="amount to pay, in millisatoshis (mSat)", + examples=[1337000], + ), + ], +) -> LnurlPayActionResponse | JSONResponse: + settings: PhoenixdLNURLSettings = request.app.state.settings + if username != settings.username: + return JSONResponse( + status_code=status.HTTP_404_NOT_FOUND, + content=LnurlErrorResponse(reason="Unknown user").dict(), + ) + username = settings.username + + # TODO check compatibility of conversion to sats, some wallets + # may not like the invoice amount not matching? + amount_sat = math.ceil(amount / 1000) + logger.info( + "LUD-06 payRequestCallback for username='{username}' sat={amount_sat} (mSat={amount})", + username=username, + amount_sat=amount_sat, + amount=amount, + ) + + if amount_sat < settings.min_sats_receivable: + logger.warning( + "LUD-06 payRequestCallback with too-low amount {amount_sat} sats", + amount_sat=amount_sat, + ) + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content=LnurlErrorResponse( + reason=f"Amount is too low, minimum is {settings.min_sats_receivable} sats" + ).dict(), + ) + + if amount_sat > settings.max_sats_receivable: + logger.warning( + "LUD-06 payRequestCallback with too-high amount {amount_sat} sats", + amount_sat=amount_sat, + ) + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content=LnurlErrorResponse( + reason=f"Amount is too high, maximum is {settings.max_sats_receivable} sats" + ).dict(), + ) + + metadata_hash = hashlib.sha256( + metadata_for_payrequest(settings).encode("UTF-8") + ).hexdigest() + invoice: CreateInvoiceResponse = ( + await request.app.state.phoenixd_client.createinvoice( + amount_sat=amount_sat, + description=metadata_hash, + external_id=metadata_hash, + ) + ) + return LnurlPayActionResponse.parse_obj( + dict( + pr=invoice.serialized, + success_action={ + "tag": "message", + "message": f"Thanks for zapping {username}", + }, + routes=[], + ) + ) + + +async def base_exception_handler( + request: Request, + exc: Exception, + status_code: int = 400, + include_detail: bool = False, +) -> JSONResponse: + if include_detail: + reason = f"{exc.__class__.__name__} {str(exc)}" + else: + reason = "Internal Server Error" + return JSONResponse( + content=LnurlErrorResponse(reason=reason).dict(), + status_code=status_code, + ) + + +def register_exception_handlers(app: FastAPI): + @app.exception_handler(RequestValidationError) + async def request_validation_handler( + request: Request, exc: Exception + ) -> JSONResponse: + return await base_exception_handler(request, exc, include_detail=True) + + @app.exception_handler(StarletteHTTPException) + async def http_handler(request: Request, exc: Exception) -> JSONResponse: + return await base_exception_handler(request, exc, include_detail=True) + + @app.exception_handler(TimeoutError) + async def timeout_handler(request: Request, exc: Exception) -> JSONResponse: + return await base_exception_handler( + request, + exc, + status_code=500, + include_detail=request.app.debug, + ) + + @app.exception_handler(Exception) + async def default_handler(request: Request, exc: Exception) -> JSONResponse: + return await base_exception_handler( + request, + exc, + status_code=500, + include_detail=request.app.debug, + ) + + +def configure_logging(loglevel: str = "INFO"): + logger.remove() + intercept_logging() + logger.add( + sys.stdout, + colorize=True, + level=loglevel, + format="{time:%Y-%m-%d:%H:%m:%S} {level:9} {message}", + ) + + +def app_factory() -> FastAPI: + # Settings are auto-loaded from a `.env` file + settings: PhoenixdLNURLSettings = PhoenixdLNURLSettings() # type: ignore + if settings.is_test: + settings = PhoenixdLNURLSettings(_env_file="test.env") # type: ignore + + configure_logging(settings.log_level) + logger.debug("Loaded settings: {settings}", settings=settings) + + if not settings.debug: + sys.tracebacklimit = 0 + + @asynccontextmanager + async def lifespan_context(app: FastAPI): + """ + Use a shared aiohttp ClientSession for the lifetime of the ASGI app + """ + app.state.client_session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout( + total=10.0, + connect=2.0, + ) + ) + if settings.is_test: + app.state.phoenixd_client = PhoenixdMockClient( + phoenixd_url=settings.phoenixd_url.get_secret_value(), + ) + else: + app.state.phoenixd_client = PhoenixdHttpClient( + session=app.state.client_session, + phoenixd_url=settings.phoenixd_url.get_secret_value(), + ) + yield + await app.state.client_session.close() + + app = FastAPI( + debug=settings.debug, + title="phoenixd-lnurl", + version="0.1.0", + license_info={ + "name": "BSD-2-Clause", + "url": "https://raw.githubusercontent.com/AngusP/phoenixd-lnurl/master/LICENSE.BSD-2-Clause", + }, + lifespan=lifespan_context, + logger=logger, + ) + app.state.settings = settings + app.add_middleware( + CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"] + ) + app.include_router(router) + register_exception_handlers(app) + return app diff --git a/app/main_test.py b/app/main_test.py new file mode 100644 index 0000000..81ca36b --- /dev/null +++ b/app/main_test.py @@ -0,0 +1,208 @@ +import json + +from fastapi.testclient import TestClient +from lnurl import ( + LnurlPayActionResponse, + LnurlPayResponse, +) + +from .main import app_factory + +app = app_factory() +test_client = TestClient(app) + + +def test_read_main(): + response = test_client.get("/") + assert response.status_code == 400 + assert response.json() == {"status": "ERROR", "reason": "HTTPException "} + + +def test_lnurl_get_lud01(): + response = test_client.get("/lnurl") + assert response.status_code == 200 + assert response.text.startswith("") + # Some basic checks to ensure config from `test.env` made it through: + assert 'href="lnurlp:satoshi@127.0.0.1"' in response.text + assert ( + 'href="lightning:LNURL1DP68GURN8GHJ7VFJXUHRQT3S9CCJ7MRWW4EXCUP0WDSHGMMNDP5S4SDZXR"' + in response.text + ) + assert ( + "nostr:npub10pensatlcfwktnvjjw2dtem38n6rvw8g6fv73h84cuacxn4c28eqyfn34f" + in response.text + ) + + +def test_lnurl_pay_request_lud06_happy(): + response = test_client.get("/lnurlp/satoshi") + assert LnurlPayResponse.parse_obj(response.json()) == LnurlPayResponse.parse_obj( + dict( + callback="https://127.0.0.1/lnurlp/satoshi/callback", + minSendable=1000, + maxSendable=500_000, + metadata=json.dumps( + [ + ["text/plain", "Zap satoshi some sats"], + ["text/identifier", "satoshi@127.0.0.1"], + ] + ), + ) + ) + assert response.status_code == 200 + + +def test_lnurl_pay_request_lud06_unknown_user(): + response = test_client.get("/lnurlp/notsatoshi") + assert response.json() == { + "status": "ERROR", + "reason": "Unknown user", + } + assert response.status_code == 404 + + +def test_lnurl_pay_request_lud06_bad_user(): + response = test_client.get("/lnurlp/BOBBYTABLES") + # NOTE this error message is less verbose when `DEBUG=False` + assert response.json() == { + "status": "ERROR", + "reason": ( + "RequestValidationError 1 validation error for Request\n" + "path -> username\n" + ' string does not match regex "^[a-z0-9-_\\.]+$" ' + "(type=value_error.str.regex; pattern=^[a-z0-9-_\\.]+$)" + ), + } + assert response.status_code == 400 + + +def test_lnurl_pay_request_lud16_happy(): + response = test_client.get("/.well-known/lnurlp/satoshi") + assert LnurlPayResponse.parse_obj(response.json()) == LnurlPayResponse.parse_obj( + dict( + callback="https://127.0.0.1/lnurlp/satoshi/callback", + minSendable=1000, + maxSendable=500_000, + metadata=json.dumps( + [ + ["text/plain", "Zap satoshi some sats"], + ["text/identifier", "satoshi@127.0.0.1"], + ] + ), + ) + ) + assert response.status_code == 200 + + +def test_lnurl_pay_request_lud16_unknown_user(): + response = test_client.get("/.well-known/lnurlp/notsatoshi") + assert response.json() == { + "status": "ERROR", + "reason": "Unknown user", + } + assert response.status_code == 404 + + +def test_lnurl_pay_request_lud16_bad_user(): + response = test_client.get("/.well-known/lnurlp/BOBBYTABLES") + # NOTE this error message is less verbose when `DEBUG=False` + assert response.json() == { + "status": "ERROR", + "reason": ( + "RequestValidationError 1 validation error for Request\n" + "path -> username\n" + ' string does not match regex "^[a-z0-9-_\\.]+$" ' + "(type=value_error.str.regex; pattern=^[a-z0-9-_\\.]+$)" + ), + } + assert response.status_code == 400 + + +def test_lnurl_pay_request_callback_lud06_happy(): + # NOTE we need to use a test client context to ensure lifespan + # startup/teardown code is run, otherwise `app.state...` may not exist + with TestClient(app) as local_client: + response = local_client.get( + "/lnurlp/satoshi/callback", + # NOTE the amount here is millisatoshis + params=[("amount", 1337 * 1000)], + ) + assert LnurlPayActionResponse.parse_obj( + response.json() + ) == LnurlPayActionResponse.parse_obj( + dict( + pr=( + "lntb1u1pnquurmpp5xr83mlrg4d79e5w8nsrq6fksq83kreptr8uvcyy3" + "0r2fsve9n6fqcqpjsp5ut3l5lvwpwyjcqf508nzdtze65zl2yycm45uee" + "elktu3phzv2fsq9q7sqqqqqqqqqqqqqqqqqqqsqqqqqysgqdrytddjyar" + "90p69ctmsd3skjm3z9s395ctsypekzar0wd5xjgja93djyar90p69ctmf" + "v3jkuarfve5k2u3z9s38xct5daeks6fzt4wsmqz9grzjqwfn3p9278ttz" + "zpe0e00uhyxhned3j5d9acqak5emwfpflp8z2cnflcdkeu6euv7gsqqqq" + "lgqqqqqeqqjqvyrulmkm8x58s9vahdm3z7jlj00pgl04xhfd0gjlm0e5e" + "z7llfg49ra6pl96808deh95ysvmxajhfse4033k2deh58mrgdjj8kz8s6" + "gpd82r8j" + ), + routes=[], + success_action={ + "tag": "message", + "message": "Thanks for zapping satoshi", + }, + ) + ) + assert response.status_code == 200 + + +def test_lnurl_pay_request_callback_lud06_unknown_user(): + with TestClient(app) as local_client: + response = local_client.get( + "/lnurlp/notsatoshi/callback", + params=[("amount", 1337000)], + ) + assert response.json() == { + "status": "ERROR", + "reason": "Unknown user", + } + assert response.status_code == 404 + + +def test_lnurl_pay_request_callback_lud06_amount_too_low(): + with TestClient(app) as local_client: + response = local_client.get( + "/lnurlp/satoshi/callback", + # 1000 mSat == 1 sat + params=[("amount", 1000)], + ) + assert response.json() == { + "status": "ERROR", + "reason": "Amount is too low, minimum is 1000 sats", + } + assert response.status_code == 400 + + +def test_lnurl_pay_request_callback_lud06_amount_too_high(): + with TestClient(app) as local_client: + response = local_client.get( + "/lnurlp/satoshi/callback", params=[("amount", 500_001_000)] + ) + assert response.json() == { + "status": "ERROR", + "reason": "Amount is too high, maximum is 500000 sats", + } + assert response.status_code == 400 + + +def test_lnurl_pay_request_callback_lud06_bad_amount(): + with TestClient(app) as local_client: + response = local_client.get( + "/lnurlp/satoshi/callback", params=[("amount", -100_000)] + ) + assert response.json() == { + "status": "ERROR", + "reason": ( + "RequestValidationError 1 validation error for Request\n" + "query -> amount\n" + " ensure this value is greater than 0 " + "(type=value_error.number.not_gt; limit_value=0)" + ), + } + assert response.status_code == 400 diff --git a/app/phoenixd_client.py b/app/phoenixd_client.py new file mode 100644 index 0000000..a797219 --- /dev/null +++ b/app/phoenixd_client.py @@ -0,0 +1,284 @@ +from abc import ( + ABC, + abstractmethod, +) + +import aiohttp +from loguru import logger +from pydantic import ( + BaseModel, + Field, +) +from yarl import URL + + +class ChannelInfo(BaseModel): + state: str + channel_id: str = Field(alias="channelId") + balance_dat: int = Field(alias="balanceSat", ge=0) + inbound_liquidity_sat: int = Field(alias="inboundLiquiditySat", ge=0) + capacity_sat: int = Field(alias="capacitySat", ge=0) + funding_tx_id: str = Field(alias="fundingTxId") + + +class GetInfoResponse(BaseModel): + node_id: str = Field(alias="nodeId") + channels: list[ChannelInfo] + chain: str + version: str + + +class GetBalanceResponse(BaseModel): + balance_sat: int = Field(alias="balanceSat", ge=0) + fee_credit_sat: int = Field(alias="feeCreditSat", ge=0) + + +class ListChannelsResponse(BaseModel): + channels: list[dict] + + +class CreateInvoiceResponse(BaseModel): + amount_sat: int = Field(alias="amountSat", ge=0) + payment_hash: str = Field(alias="paymentHash") + serialized: str + + +class PhoenixdClientBase(ABC): + @abstractmethod + async def getinfo(self) -> GetInfoResponse: ... + + @abstractmethod + async def getbalance(self) -> GetBalanceResponse: ... + + @abstractmethod + async def listchannels(self) -> ListChannelsResponse: ... + + @abstractmethod + async def closechannel( + self, + *, + channel_id: str, + address: str, + feerate_sat_vbyte: int, + ): ... + + @abstractmethod + async def createinvoice( + self, + *, + amount_sat: int, + description: str | bytes, + external_id: str | None = None, + ) -> CreateInvoiceResponse: ... + + @abstractmethod + async def payinvoice( + self, + *, + invoice: str, + amount_sat: int | None = None, + ): ... + + @abstractmethod + async def sendtoaddress( + self, + *, + address: str, + amount_sat: int, + feerate_sat_vbyte: int, + ): ... + + @abstractmethod + async def incoming_payments_external_id(self, external_id: str): ... + + @abstractmethod + async def incoming_payment_hash(self, hash: str | bytes): ... + + @abstractmethod + async def outgoing_payment_id(self, payment_id: str): ... + + @abstractmethod + async def payments_websocket(self): ... + + +class PhoenixdHttpClient(PhoenixdClientBase): + def __init__( + self, + *, + session: aiohttp.ClientSession, + phoenixd_url: str | URL, + ): + self.session = session + self.baseurl = ( + phoenixd_url if isinstance(phoenixd_url, URL) else URL(phoenixd_url) + ) + + async def getinfo(self) -> GetInfoResponse: + async with self.session.get(self.baseurl / "getinfo") as response: + return GetInfoResponse.parse_obj(await response.json()) + + async def getbalance(self) -> GetBalanceResponse: + async with self.session.get(self.baseurl / "getbalance") as response: + return GetBalanceResponse.parse_obj(await response.json()) + + async def listchannels(self) -> ListChannelsResponse: + async with self.session.get(self.baseurl / "listchannels") as response: + return ListChannelsResponse.parse_obj(await response.json()) + + async def closechannel( + self, + *, + channel_id: str, + address: str, + feerate_sat_vbyte: int, + ): + raise NotImplementedError() + + async def createinvoice( + self, + *, + amount_sat: int, + description: str | bytes, + external_id: str | None = None, + ) -> CreateInvoiceResponse: + form_data = { + "amountSat": amount_sat, + "description": description, + } + if external_id is not None: + form_data["externalId"] = external_id + async with self.session.post( + self.baseurl / "createinvoice", + data=form_data, + ) as response: + invoice = CreateInvoiceResponse.parse_obj(await response.json()) + logger.info( + "Created invoice {inv_short}... externalId: '{external_id}'", + inv_short=invoice.serialized[:12], + external_id=external_id, + ) + logger.debug("Invoice: {invoice}", invoice=invoice) + return invoice + + async def payinvoice( + self, + *, + invoice: str, + amount_sat: int | None = None, + ): + raise NotImplementedError() + + async def sendtoaddress( + self, + *, + address: str, + amount_sat: int, + feerate_sat_vbyte: int, + ): + raise NotImplementedError() + + async def incoming_payments_external_id(self, external_id: str): + raise NotImplementedError() + + async def incoming_payment_hash(self, hash: str | bytes): + raise NotImplementedError() + + async def outgoing_payment_id(self, payment_id: str): + raise NotImplementedError() + + async def payments_websocket(self): + raise NotImplementedError() + + +class PhoenixdMockClient(PhoenixdClientBase): + def __init__( + self, + *, + phoenixd_url: str | URL, + ): + self.baseurl = ( + phoenixd_url if isinstance(phoenixd_url, URL) else URL(phoenixd_url) + ) + + async def getinfo(self) -> GetInfoResponse: + raise NotImplementedError() + + async def getbalance(self) -> GetBalanceResponse: + raise NotImplementedError() + + async def listchannels(self) -> ListChannelsResponse: + raise NotImplementedError() + + async def closechannel( + self, + *, + channel_id: str, + address: str, + feerate_sat_vbyte: int, + ): + raise NotImplementedError() + + async def createinvoice( + self, + *, + amount_sat: int, + description: str | bytes, + external_id: str | None = None, + ) -> CreateInvoiceResponse: + # Static mock invoice + invoice = CreateInvoiceResponse.parse_obj( + { + "amountSat": amount_sat, + "paymentHash": ( + "30cf1dfc68ab7c5cd1c79c060d26d001e361e42b19f8cc109178d4983" + "3259e92" + ), + "serialized": ( + "lntb1u1pnquurmpp5xr83mlrg4d79e5w8nsrq6fksq83kreptr8uvcyy3" + "0r2fsve9n6fqcqpjsp5ut3l5lvwpwyjcqf508nzdtze65zl2yycm45uee" + "elktu3phzv2fsq9q7sqqqqqqqqqqqqqqqqqqqsqqqqqysgqdrytddjyar" + "90p69ctmsd3skjm3z9s395ctsypekzar0wd5xjgja93djyar90p69ctmf" + "v3jkuarfve5k2u3z9s38xct5daeks6fzt4wsmqz9grzjqwfn3p9278ttz" + "zpe0e00uhyxhned3j5d9acqak5emwfpflp8z2cnflcdkeu6euv7gsqqqq" + "lgqqqqqeqqjqvyrulmkm8x58s9vahdm3z7jlj00pgl04xhfd0gjlm0e5e" + "z7llfg49ra6pl96808deh95ysvmxajhfse4033k2deh58mrgdjj8kz8s6" + "gpd82r8j" + ), + } + ) + logger.info( + "Created invoice {inv_short}... externalId: '{external_id}'", + inv_short=invoice.serialized[:12], + external_id=external_id, + ) + logger.debug("Invoice: {invoice}", invoice=invoice) + return invoice + + async def payinvoice( + self, + *, + invoice: str, + amount_sat: int | None = None, + ): + raise NotImplementedError() + + async def sendtoaddress( + self, + *, + address: str, + amount_sat: int, + feerate_sat_vbyte: int, + ): + raise NotImplementedError() + + async def incoming_payments_external_id(self, external_id: str): + raise NotImplementedError() + + async def incoming_payment_hash(self, hash: str | bytes): + raise NotImplementedError() + + async def outgoing_payment_id(self, payment_id: str): + raise NotImplementedError() + + async def payments_websocket(self): + raise NotImplementedError() diff --git a/app/phoenixd_client_test.py b/app/phoenixd_client_test.py new file mode 100644 index 0000000..aead0e1 --- /dev/null +++ b/app/phoenixd_client_test.py @@ -0,0 +1,38 @@ +import pytest + +from .phoenixd_client import ( + CreateInvoiceResponse, + PhoenixdMockClient, +) +from .settings import PhoenixdLNURLSettings + + +@pytest.mark.asyncio +async def test_mock_client_createinvoice(): + settings = PhoenixdLNURLSettings(_env_file="test.env") + client = PhoenixdMockClient(phoenixd_url=settings.phoenixd_url.get_secret_value()) + inv = await client.createinvoice( + amount_sat=1337, + description="demo", + external_id="test_inv", + ) + assert ( + CreateInvoiceResponse( + amountSat=1337, + paymentHash=( + "30cf1dfc68ab7c5cd1c79c060d26d001e361e42b19f8cc109178d49833259e92" + ), + serialized=( + "lntb1u1pnquurmpp5xr83mlrg4d79e5w8nsrq6fksq83kreptr8uvcyy3" + "0r2fsve9n6fqcqpjsp5ut3l5lvwpwyjcqf508nzdtze65zl2yycm45uee" + "elktu3phzv2fsq9q7sqqqqqqqqqqqqqqqqqqqsqqqqqysgqdrytddjyar" + "90p69ctmsd3skjm3z9s395ctsypekzar0wd5xjgja93djyar90p69ctmf" + "v3jkuarfve5k2u3z9s38xct5daeks6fzt4wsmqz9grzjqwfn3p9278ttz" + "zpe0e00uhyxhned3j5d9acqak5emwfpflp8z2cnflcdkeu6euv7gsqqqq" + "lgqqqqqeqqjqvyrulmkm8x58s9vahdm3z7jlj00pgl04xhfd0gjlm0e5e" + "z7llfg49ra6pl96808deh95ysvmxajhfse4033k2deh58mrgdjj8kz8s6" + "gpd82r8j" + ), + ) + == inv + ) diff --git a/app/settings.py b/app/settings.py new file mode 100644 index 0000000..e3f90b2 --- /dev/null +++ b/app/settings.py @@ -0,0 +1,40 @@ +from pydantic import ( + BaseSettings, + Field, + HttpUrl, + SecretStr, +) +from yarl import URL + +MAX_CORN = 21_000_000 * 100_000_000 + + +class PhoenixdLNURLSettings(BaseSettings): + username: str = Field(regex=r"^[a-z0-9-_\.]+$") + lnurl_hostname: str + phoenixd_url: SecretStr + + min_sats_receivable: int = Field(default=1, ge=1) + max_sats_receivable: int = Field(default=MAX_CORN, ge=1) + user_profile_image_url: HttpUrl | None = None + user_nostr_address: str | None = None + log_level: str = "INFO" + + # Enable development/debug features. Unsafe on prod. + debug: bool = False + # Set in test environments. Unsafe on prod. + is_test: bool = False + + def base_url(self) -> URL: + # TODO support `http` for `.onion` only (per LNURL spec) + return URL(f"https://{self.lnurl_hostname}") + + def lnurl_address(self) -> str: + return f"{self.username}@{self.lnurl_hostname}" + + def is_long_username(self) -> bool: + return len(self.username) > 10 + + class Config: + env_file = "phoenixd-lnurl.env" + env_file_encoding = "utf-8" diff --git a/app/settings_test.py b/app/settings_test.py new file mode 100644 index 0000000..c0e4795 --- /dev/null +++ b/app/settings_test.py @@ -0,0 +1,46 @@ +from pydantic import SecretStr +from yarl import URL + +from .settings import PhoenixdLNURLSettings + + +def test_testenv_settings_load(): + settings = PhoenixdLNURLSettings(_env_file="test.env") + assert settings.is_test is True + assert settings.debug is True + assert settings.username == "satoshi" + assert settings.lnurl_hostname == "127.0.0.1" + assert settings.phoenixd_url == SecretStr("http://satoshi:hunter2@127.0.0.1:9740") + assert ( + settings.user_nostr_address + == "npub10pensatlcfwktnvjjw2dtem38n6rvw8g6fv73h84cuacxn4c28eqyfn34f" + ) + assert settings.user_profile_image_url == "https://bitcoin.org/satoshi.png" + assert settings.log_level == "DEBUG" + assert settings.min_sats_receivable == 1000 + assert settings.max_sats_receivable == 500_000 + + +def test_default_settings_load(): + settings = PhoenixdLNURLSettings(_env_file="phoenixd-lnurl.env.example") + # NOTE this is not set in the .env but overridden by environment variable: + assert settings.is_test is True + assert settings.debug is False + assert settings.username == "satoshi" + assert settings.lnurl_hostname == "bitcoincore.org" + assert settings.phoenixd_url == SecretStr("http://satoshi:hunter2@127.0.0.1:9740") + assert settings.user_nostr_address is None + assert settings.user_profile_image_url is None + assert settings.log_level == "INFO" + assert settings.min_sats_receivable == 1 + assert settings.max_sats_receivable == 2.1e15 + + +def test_testenv_settings_derived_properties(): + settings = PhoenixdLNURLSettings(_env_file="test.env") + assert settings.base_url() == URL("https://127.0.0.1") + assert settings.lnurl_address() == "satoshi@127.0.0.1" + assert settings.is_long_username() is False + + settings.username = "marttimalmi" + assert settings.is_long_username() is True diff --git a/app/setup_logging.py b/app/setup_logging.py new file mode 100644 index 0000000..07b988e --- /dev/null +++ b/app/setup_logging.py @@ -0,0 +1,24 @@ +import logging + +from loguru import logger + + +class InterceptHandler(logging.Handler): + def emit(self, record: logging.LogRecord): + # Get corresponding Loguru level if it exists + try: + level = logger.level(record.levelname).name + except ValueError: + level = "INFO" + logger.opt(exception=record.exc_info).log(level, record.getMessage()) + + +def intercept_logging(): + """ + Replaces logging handlers with a loguru intercept + """ + intercept_handler = InterceptHandler() + # Redirect all stdlib loggers loggers to the intercept handler + for logger_name in logging.root.manager.loggerDict: + found_logger = logging.getLogger(logger_name) + found_logger.handlers = [intercept_handler] diff --git a/app/templates/lnurl-splash.html b/app/templates/lnurl-splash.html new file mode 100644 index 0000000..c216ea8 --- /dev/null +++ b/app/templates/lnurl-splash.html @@ -0,0 +1,177 @@ + + + + + Zap {{ username.title() }} + + + + + + + + + + + + +
+
+
+ {% if smaller_heading %} +

⚡️ Zap {{ username.title()}} ⚡️

+ {% else %} +

⚡️  Zap {{ username.title()}}  ⚡️

+ {% endif %} +
+ + {{ lnurl_qr | safe }} + + {% if profile_image_url -%} + + {%- endif %} +
+ Lightning Address
+ {{ lnurl_address }}

+ {% if nostr_address -%} + Nostr
+ {{ nostr_address }}
+ {%- endif %} +
+
+ +
+ + + \ No newline at end of file diff --git a/app/wsgi.py b/app/wsgi.py new file mode 100644 index 0000000..af297ab --- /dev/null +++ b/app/wsgi.py @@ -0,0 +1,4 @@ +#!/usr/bin/env python +from .main import app_factory + +app = app_factory() diff --git a/img/screenshot.jpg b/img/screenshot.jpg new file mode 100644 index 0000000..9ebe7a6 Binary files /dev/null and b/img/screenshot.jpg differ diff --git a/img/screenshot_ios.png b/img/screenshot_ios.png new file mode 100644 index 0000000..31ac252 Binary files /dev/null and b/img/screenshot_ios.png differ diff --git a/img/screenshot_web.png b/img/screenshot_web.png new file mode 100644 index 0000000..d07ef0e Binary files /dev/null and b/img/screenshot_web.png differ diff --git a/justfile b/justfile new file mode 100644 index 0000000..14e5caa --- /dev/null +++ b/justfile @@ -0,0 +1,61 @@ +_default: + @just --list --unsorted --justfile {{justfile()}} + +# Install development dependencies +install: + @echo "📦 Installing requirements..." + pip install pip-tools + pip-sync requirements-dev.txt + @echo "✅ Done" + +# Run a dev server +serve: + #!/usr/bin/env bash + _=$(lsof -i:8000 -P -n -t) + if [ "$?" == "1" ]; then + uvicorn app.main:app_factory --factory --reload --reload-include '*.env'; + else + echo "💡 Port 8000 is in use, the server is probably already running" + fi + +# Update package dependencies (can change requirements) +update: + pip-compile -Uq --strip-extras + pip-compile -Uq --strip-extras requirements-dev.in + @git diff requirements* + @echo "🤔 Rememeber to run 'just install' to apply package updates!" + +# Run python linters and tests (just checks code) +test: lint pytest mypy + +# Run python formatters (can change code) +format: + ruff check --select I --fix + ruff format + ruff check --fix + +alias fmt := format + +# Run python linters (just checks code) +lint: + ruff check + +# Run python tests +pytest *pytest_args="-vx": + IS_TEST=1 pytest {{pytest_args}} + +# Run python type checking +mypy *files=".": + mypy {{files}} + +open_cmd := if os() == "macos" { "open" } else if os() == "windows" { "wslview" } else { "xdg-open" } + +# Open auto-generated API docs +docs: + #!/usr/bin/env bash + _=$(lsof -i:8000 -P -n -t) + if [ "$?" == "1" ]; then + echo "😱 You need to run 'just serve' first to view the docs" + else + {{open_cmd}} 'http://127.0.0.1:8000/docs' + fi diff --git a/phoenixd-lnurl.env.example b/phoenixd-lnurl.env.example new file mode 100644 index 0000000..e649368 --- /dev/null +++ b/phoenixd-lnurl.env.example @@ -0,0 +1,30 @@ +## What username do you want? Note this has to be `a-z0-9-_.` per the LNURL spec, +## it's not as expressive as an email address. +USERNAME=satoshi + +## The hostname (not including `https://` etc.) where you'll be running +## your LNURL. This is used to make your address e.g. `satoshi@example.com`, +## and also to provide the correct URLs in responses to payment requests. +LNURL_HOSTNAME='example.com' + +## URL of your `phoenixd` instance. Note the username (`satoshi` here) +## is not used, but you have to set it to something. `satoshi` or `_` work well. +## Your `phoenixd` instance will put its password in `~/.phoenix/phoenix.conf` +PHOENIXD_URL='http://satoshi:hunter2@127.0.0.1:9740' + +## Optional: set the minimum number of sats you wish to recieve in a single payment (default: 1) +# MIN_SATS_RECEIVABLE=1 +## Optional: set the minimum number of sats you wish to recieve in a single payment (default: all of the sats) +# MAX_SATS_RECEIVABLE=2100000000000000 + +## Optional; set to show a profile photo on your tips page `/lnurl` +# USER_PROFILE_IMAGE_URL='https://example.com/your_profile_photo.png' + +## Optional; set to show an `npub` or `nprofile` or NIP5 identifier on your tips page `/lnurl` +# USER_NOSTR_ADDRESS='npub1...' + +## Optional & Technical: Change the log level. Values: "INFO" (default), "DEBUG", "WARNING", etc. +# LOG_LEVEL='DEBUG' + +## WARNING: Intended for development only, enables useful but dangerous-in-public debug features +# DEBUG=1 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1681914 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[tool.ruff] +line-length = 88 + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +docstring-code-format = true +docstring-code-line-length = 80 +line-ending = "lf" + +[tool.ruff.lint] +select = [ + # pycodestyle + "E", + # Pyflakes + "F", + # pyupgrade + "UP", + # flake8-bugbear + "B", + # flake8-simplify + "SIM", + # isort + "I", +] +ignore = ["E501"] +fixable = ["ALL"] + +[tool.ruff.lint.flake8-quotes] +docstring-quotes = "double" diff --git a/requirements-dev.in b/requirements-dev.in new file mode 100644 index 0000000..e61434f --- /dev/null +++ b/requirements-dev.in @@ -0,0 +1,10 @@ +-r requirements.in +-c requirements.txt + +httpx +ipython +mypy +pytest +pytest-asyncio +ruff +types-qrcode diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..c8c2684 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,258 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --strip-extras requirements-dev.in +# +aiohttp==3.9.3 + # via + # -c requirements.txt + # -r requirements.in +aiosignal==1.3.1 + # via + # -c requirements.txt + # aiohttp +anyio==4.3.0 + # via + # -c requirements.txt + # httpx + # starlette + # watchfiles +asttokens==2.4.1 + # via stack-data +attrs==23.2.0 + # via + # -c requirements.txt + # aiohttp +bech32==1.2.0 + # via + # -c requirements.txt + # lnurl +certifi==2024.2.2 + # via + # -c requirements.txt + # httpcore + # httpx + # requests +charset-normalizer==3.3.2 + # via + # -c requirements.txt + # requests +click==8.1.7 + # via + # -c requirements.txt + # uvicorn +decorator==5.1.1 + # via ipython +dnspython==2.6.1 + # via + # -c requirements.txt + # email-validator +email-validator==2.1.1 + # via + # -c requirements.txt + # fastapi +executing==2.0.1 + # via stack-data +fastapi==0.99.1 + # via + # -c requirements.txt + # -r requirements.in +frozenlist==1.4.1 + # via + # -c requirements.txt + # aiohttp + # aiosignal +gunicorn==21.2.0 + # via + # -c requirements.txt + # -r requirements.in +h11==0.14.0 + # via + # -c requirements.txt + # httpcore + # uvicorn +httpcore==1.0.5 + # via + # -c requirements.txt + # httpx +httptools==0.6.1 + # via + # -c requirements.txt + # uvicorn +httpx==0.27.0 + # via + # -c requirements.txt + # -r requirements-dev.in + # fastapi +idna==3.6 + # via + # -c requirements.txt + # anyio + # email-validator + # httpx + # requests + # yarl +iniconfig==2.0.0 + # via pytest +ipython==8.23.0 + # via -r requirements-dev.in +itsdangerous==2.1.2 + # via + # -c requirements.txt + # fastapi +jedi==0.19.1 + # via ipython +jinja2==3.1.3 + # via + # -c requirements.txt + # -r requirements.in + # fastapi +lnurl==0.4.2 + # via + # -c requirements.txt + # -r requirements.in +loguru==0.7.2 + # via + # -c requirements.txt + # -r requirements.in +markupsafe==2.1.5 + # via + # -c requirements.txt + # jinja2 +matplotlib-inline==0.1.6 + # via ipython +multidict==6.0.5 + # via + # -c requirements.txt + # aiohttp + # yarl +mypy==1.9.0 + # via -r requirements-dev.in +mypy-extensions==1.0.0 + # via mypy +orjson==3.10.0 + # via + # -c requirements.txt + # fastapi +packaging==24.0 + # via + # -c requirements.txt + # gunicorn + # pytest +parso==0.8.3 + # via jedi +pexpect==4.9.0 + # via ipython +pillow==10.3.0 + # via + # -c requirements.txt + # qrcode +pluggy==1.4.0 + # via pytest +prompt-toolkit==3.0.43 + # via ipython +ptyprocess==0.7.0 + # via pexpect +pure-eval==0.2.2 + # via stack-data +pydantic==1.10.14 + # via + # -c requirements.txt + # -r requirements.in + # fastapi + # lnurl +pygments==2.17.2 + # via ipython +pypng==0.20220715.0 + # via + # -c requirements.txt + # qrcode +pytest==8.1.1 + # via + # -r requirements-dev.in + # pytest-asyncio +pytest-asyncio==0.23.6 + # via -r requirements-dev.in +python-dotenv==1.0.1 + # via + # -c requirements.txt + # pydantic + # uvicorn +python-multipart==0.0.9 + # via + # -c requirements.txt + # fastapi +pyyaml==6.0.1 + # via + # -c requirements.txt + # fastapi + # uvicorn +qrcode==7.4.2 + # via + # -c requirements.txt + # -r requirements.in +requests==2.31.0 + # via + # -c requirements.txt + # lnurl +ruff==0.3.5 + # via -r requirements-dev.in +six==1.16.0 + # via asttokens +sniffio==1.3.1 + # via + # -c requirements.txt + # anyio + # httpx +stack-data==0.6.3 + # via ipython +starlette==0.27.0 + # via + # -c requirements.txt + # fastapi +traitlets==5.14.2 + # via + # ipython + # matplotlib-inline +types-qrcode==7.4.0.20240106 + # via -r requirements-dev.in +typing-extensions==4.10.0 + # via + # -c requirements.txt + # fastapi + # mypy + # pydantic + # qrcode +ujson==5.9.0 + # via + # -c requirements.txt + # fastapi +urllib3==2.2.1 + # via + # -c requirements.txt + # requests +uvicorn==0.29.0 + # via + # -c requirements.txt + # -r requirements.in + # fastapi +uvloop==0.19.0 + # via + # -c requirements.txt + # uvicorn +watchfiles==0.21.0 + # via + # -c requirements.txt + # uvicorn +wcwidth==0.2.13 + # via prompt-toolkit +websockets==12.0 + # via + # -c requirements.txt + # uvicorn +yarl==1.9.4 + # via + # -c requirements.txt + # -r requirements.in + # aiohttp diff --git a/requirements.in b/requirements.in new file mode 100644 index 0000000..daf2a26 --- /dev/null +++ b/requirements.in @@ -0,0 +1,10 @@ +aiohttp +fastapi[all] +gunicorn +jinja2 +lnurl +loguru +pydantic[dotenv]==1.10.14 # NOTE stuck on <2.0.0 because of lnurl compat issue +qrcode[pil] +uvicorn[standard] +yarl diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2c2fd5a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,129 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --strip-extras +# +aiohttp==3.9.3 + # via -r requirements.in +aiosignal==1.3.1 + # via aiohttp +anyio==4.3.0 + # via + # httpx + # starlette + # watchfiles +attrs==23.2.0 + # via aiohttp +bech32==1.2.0 + # via lnurl +certifi==2024.2.2 + # via + # httpcore + # httpx + # requests +charset-normalizer==3.3.2 + # via requests +click==8.1.7 + # via uvicorn +dnspython==2.6.1 + # via email-validator +email-validator==2.1.1 + # via fastapi +fastapi==0.99.1 + # via -r requirements.in +frozenlist==1.4.1 + # via + # aiohttp + # aiosignal +gunicorn==21.2.0 + # via -r requirements.in +h11==0.14.0 + # via + # httpcore + # uvicorn +httpcore==1.0.5 + # via httpx +httptools==0.6.1 + # via uvicorn +httpx==0.27.0 + # via fastapi +idna==3.6 + # via + # anyio + # email-validator + # httpx + # requests + # yarl +itsdangerous==2.1.2 + # via fastapi +jinja2==3.1.3 + # via + # -r requirements.in + # fastapi +lnurl==0.4.2 + # via -r requirements.in +loguru==0.7.2 + # via -r requirements.in +markupsafe==2.1.5 + # via jinja2 +multidict==6.0.5 + # via + # aiohttp + # yarl +orjson==3.10.0 + # via fastapi +packaging==24.0 + # via gunicorn +pillow==10.3.0 + # via qrcode +pydantic==1.10.14 + # via + # -r requirements.in + # fastapi + # lnurl +pypng==0.20220715.0 + # via qrcode +python-dotenv==1.0.1 + # via + # pydantic + # uvicorn +python-multipart==0.0.9 + # via fastapi +pyyaml==6.0.1 + # via + # fastapi + # uvicorn +qrcode==7.4.2 + # via -r requirements.in +requests==2.31.0 + # via lnurl +sniffio==1.3.1 + # via + # anyio + # httpx +starlette==0.27.0 + # via fastapi +typing-extensions==4.10.0 + # via + # fastapi + # pydantic + # qrcode +ujson==5.9.0 + # via fastapi +urllib3==2.2.1 + # via requests +uvicorn==0.29.0 + # via + # -r requirements.in + # fastapi +uvloop==0.19.0 + # via uvicorn +watchfiles==0.21.0 + # via uvicorn +websockets==12.0 + # via uvicorn +yarl==1.9.4 + # via + # -r requirements.in + # aiohttp diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..9d8980b --- /dev/null +++ b/run.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +exec gunicorn app.wsgi:app \ + --workers 4 \ + --worker-class uvicorn.workers.UvicornWorker \ + --forwarded-allow-ips='*' \ + --bind 127.0.0.1:8000; diff --git a/test.env b/test.env new file mode 100644 index 0000000..e0b7944 --- /dev/null +++ b/test.env @@ -0,0 +1,12 @@ +# NOTE: This .env file is used when running python tests, see +# `phoenixd-lnurl.env.example` instead for a template to copy. +IS_TEST=1 +DEBUG=1 +USERNAME=satoshi +LNURL_HOSTNAME='127.0.0.1' +PHOENIXD_URL='http://satoshi:hunter2@127.0.0.1:9740' +MIN_SATS_RECEIVABLE=1000 +MAX_SATS_RECEIVABLE=500000 +USER_PROFILE_IMAGE_URL='https://bitcoin.org/satoshi.png' +USER_NOSTR_ADDRESS='npub10pensatlcfwktnvjjw2dtem38n6rvw8g6fv73h84cuacxn4c28eqyfn34f' +LOG_LEVEL='DEBUG'