diff --git a/app/main.py b/app/main.py index 0a023e0..77b6f23 100644 --- a/app/main.py +++ b/app/main.py @@ -1,13 +1,9 @@ -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, @@ -30,7 +26,6 @@ ) 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 ( @@ -52,16 +47,6 @@ 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", @@ -71,27 +56,18 @@ def metadata_for_payrequest(settings: PhoenixdLNURLSettings) -> str: ) 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, + "lnurl_address": settings.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"), + "meta_author": settings.lnurl_address(), + "encoded_lnurl": settings.lnurl_address_encoded(), + "lnurl_qr": settings.lnurl_qr(), "smaller_heading": settings.is_long_username(), }, ) @@ -135,7 +111,7 @@ async def lnurl_pay_request_lud06( 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), + metadata=settings.metadata_for_payrequest(), ) ) @@ -178,7 +154,7 @@ async def lnurl_pay_request_lud16( 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), + metadata=settings.metadata_for_payrequest(), ) ) @@ -252,14 +228,11 @@ async def lnurl_pay_request_callback_lud06( ).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, + description=settings.metadata_hash(), + external_id=settings.metadata_hash(), ) ) return LnurlPayActionResponse.parse_obj( diff --git a/app/main_test.py b/app/main_test.py index 81ca36b..89a82d7 100644 --- a/app/main_test.py +++ b/app/main_test.py @@ -39,8 +39,9 @@ def test_lnurl_pay_request_lud06_happy(): assert LnurlPayResponse.parse_obj(response.json()) == LnurlPayResponse.parse_obj( dict( callback="https://127.0.0.1/lnurlp/satoshi/callback", - minSendable=1000, - maxSendable=500_000, + # NOTE these are millisat values + minSendable=1_000_000, + maxSendable=500_000_000, metadata=json.dumps( [ ["text/plain", "Zap satoshi some sats"], @@ -81,8 +82,9 @@ def test_lnurl_pay_request_lud16_happy(): assert LnurlPayResponse.parse_obj(response.json()) == LnurlPayResponse.parse_obj( dict( callback="https://127.0.0.1/lnurlp/satoshi/callback", - minSendable=1000, - maxSendable=500_000, + # NOTE these are millisat values + minSendable=1_000_000, + maxSendable=500_000_000, metadata=json.dumps( [ ["text/plain", "Zap satoshi some sats"], diff --git a/app/settings.py b/app/settings.py index e3f90b2..c0b6df7 100644 --- a/app/settings.py +++ b/app/settings.py @@ -1,9 +1,15 @@ +import hashlib +import json + +import lnurl +import qrcode.image.svg from pydantic import ( BaseSettings, Field, HttpUrl, SecretStr, ) +from qrcode.main import QRCode from yarl import URL MAX_CORN = 21_000_000 * 100_000_000 @@ -25,15 +31,45 @@ class PhoenixdLNURLSettings(BaseSettings): # Set in test environments. Unsafe on prod. is_test: bool = False + # TODO use @computed_field when upgrading to Pydantic 2.x + # https://docs.pydantic.dev/2.6/api/fields/#pydantic.fields.computed_field + # So we can also use @functools.cached_property + def base_url(self) -> URL: # TODO support `http` for `.onion` only (per LNURL spec) return URL(f"https://{self.lnurl_hostname}") + def is_long_username(self) -> bool: + return len(self.username) > 10 + def lnurl_address(self) -> str: return f"{self.username}@{self.lnurl_hostname}" - def is_long_username(self) -> bool: - return len(self.username) > 10 + def lnurl_address_encoded(self) -> lnurl.Lnurl: + return lnurl.encode(str(self.base_url() / "lnurlp" / self.username)) + + def lnurl_qr(self) -> str: + lnurl_qr = QRCode( + image_factory=qrcode.image.svg.SvgPathFillImage, + box_size=15, + ) + lnurl_qr.add_data(self.lnurl_address_encoded()) + lnurl_qr.make(fit=True) + return lnurl_qr.make_image().to_string(encoding="unicode") + + def metadata_for_payrequest(self) -> str: + return json.dumps( + [ + ["text/plain", f"Zap {self.username} some sats"], + ["text/identifier", self.lnurl_address()], + # ["image/jpeg;base64", "TODO optional"], + ] + ) + + def metadata_hash(self) -> str: + return hashlib.sha256( + self.metadata_for_payrequest().encode("UTF-8") + ).hexdigest() class Config: env_file = "phoenixd-lnurl.env" diff --git a/app/settings_test.py b/app/settings_test.py index c0e4795..7b4699c 100644 --- a/app/settings_test.py +++ b/app/settings_test.py @@ -27,8 +27,8 @@ def test_default_settings_load(): 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.lnurl_hostname == "example.com" + assert settings.phoenixd_url == SecretStr("http://_: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" @@ -40,6 +40,20 @@ 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.lnurl_address_encoded() + == "LNURL1DP68GURN8GHJ7VFJXUHRQT3S9CCJ7MRWW4EXCUP0WDSHGMMNDP5S4SDZXR" + ) + assert settings.lnurl_qr()[:20] == '