diff --git a/__init__.py b/__init__.py index 5c0ccdb..d1310c2 100644 --- a/__init__.py +++ b/__init__.py @@ -9,24 +9,6 @@ from lnbits.db import Database from lnbits.helpers import template_renderer from lnbits.tasks import catch_everything_and_restart -from .nostr.event import Event -from .nostr.key import PrivateKey, PublicKey - - -def generate_keys(private_key: str = ""): - if private_key.startswith("nsec"): - return PrivateKey.from_nsec(private_key) - elif private_key: - return PrivateKey(bytes.fromhex(private_key)) - else: - return PrivateKey() # generate random key - - -env = Env() -env.read_env() -nostr_privatekey = generate_keys(env.str("LNURLP_ZAP_NOSTR_PRIVATEKEY", default="")) -nostr_publickey: PublicKey = nostr_privatekey.public_key -logger.debug(f"LNURLP Zaps Nostr pubkey: {nostr_publickey.hex()}") db = Database("ext_lnurlp") diff --git a/crud.py b/crud.py index 377484d..f80cebd 100644 --- a/crud.py +++ b/crud.py @@ -1,12 +1,30 @@ from typing import List, Optional, Union -from lnbits.helpers import urlsafe_short_hash +from lnbits.helpers import urlsafe_short_hash, insert_query, update_query -from . import db # , maindb -from .models import CreatePayLinkData, PayLink +from . import db +from .models import CreatePayLinkData, LnurlpSettings, PayLink +from .nostr.key import PrivateKey from .services import check_lnaddress_format -# from loguru import logger + +async def get_or_create_lnurlp_settings() -> LnurlpSettings: + row = await db.fetchone("SELECT * FROM lnurlp.settings LIMIT 1") + if row: + return LnurlpSettings(**row) + else: + settings = LnurlpSettings(nostr_private_key=PrivateKey().hex()) + await db.execute(insert_query("lnurlp.settings", settings), (*settings.model_dump().values(),)) + return settings + + +async def update_lnurlp_settings(settings: LnurlpSettings) -> LnurlpSettings: + await db.execute(update_query("lnurlp.settings", settings, where=""), (*settings.model_dump().values(),)) + return settings + + +async def delete_lnurlp_settings() -> None: + await db.execute("DELETE FROM lnurlp.settings") async def check_lnaddress_not_exists(username: str) -> bool: diff --git a/helpers.py b/helpers.py new file mode 100644 index 0000000..599699e --- /dev/null +++ b/helpers.py @@ -0,0 +1,8 @@ +from .nostr.key import PrivateKey + + +def parse_nostr_private_key(key: str) -> PrivateKey: + if key.startswith("nsec"): + return PrivateKey.from_nsec(key) + else: + return PrivateKey(bytes.fromhex(key)) diff --git a/lnurl.py b/lnurl.py index d3703b1..57e8d90 100644 --- a/lnurl.py +++ b/lnurl.py @@ -1,3 +1,4 @@ +import json from http import HTTPStatus from urllib.parse import urlparse @@ -8,8 +9,11 @@ from starlette.exceptions import HTTPException from lnbits.core.services import create_invoice from lnbits.utils.exchange_rates import get_fiat_rate_satoshis -from . import lnurlp_ext, nostr_publickey -from .crud import increment_pay_link +from . import lnurlp_ext +from .crud import ( + get_or_create_lnurlp_settings, + increment_pay_link, +) @lnurlp_ext.get( @@ -145,6 +149,7 @@ async def api_lnurl_response(request: Request, link_id, lnaddress=False): params["commentAllowed"] = link.comment_chars if link.zaps: + settings = await get_or_create_lnurlp_settings() params["allowsNostr"] = True - params["nostrPubkey"] = nostr_publickey.hex() + params["nostrPubkey"] = settings.public_key return params diff --git a/migrations.py b/migrations.py index 705b55f..4bb4b2e 100644 --- a/migrations.py +++ b/migrations.py @@ -160,3 +160,16 @@ async def m008_add_zap_enabled_column(db): Add Nostr zaps to pay links """ await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN zaps BOOLEAN;") + + +async def m009_add_settings(db): + """ + Add extension settings table + """ + await db.execute( + """ + CREATE TABLE lnurlp.settings ( + nostr_private_key TEXT NOT NULL + ); + """ + ) diff --git a/models.py b/models.py index e710558..864900a 100644 --- a/models.py +++ b/models.py @@ -10,6 +10,21 @@ from pydantic import BaseModel from lnbits.lnurl import encode as lnurl_encode +from .helpers import parse_nostr_private_key +from .nostr.key import PrivateKey + + +class LnurlpSettings(BaseModel): + nostr_private_key: str + + @property + def private_key(self) -> PrivateKey: + return parse_nostr_private_key(self.nostr_private_key) + + @property + def public_key(self) -> str: + return self.private_key.public_key.hex() + class CreatePayLinkData(BaseModel): description: str diff --git a/static/js/index.js b/static/js/index.js index ffbb53a..5a44726 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -2,14 +2,14 @@ Vue.component(VueQrcode.name, VueQrcode) -var locationPath = [ +const locationPath = [ window.location.protocol, '//', window.location.host, window.location.pathname ].join('') -var mapPayLink = obj => { +const mapPayLink = obj => { obj._data = _.clone(obj) obj.date = Quasar.utils.date.formatDate( new Date(obj.time * 1000), @@ -24,8 +24,20 @@ var mapPayLink = obj => { new Vue({ el: '#vue', mixins: [windowMixin], + computed: { + endpoint: function() { + return `/lnurlp/api/v1/settings?usr=${this.g.user.id}` + } + }, data() { return { + settings: [ + { + "type": "str", + "description": "Nostr private key used to zap", + "name": "nostr_private_key", + } + ], domain: window.location.host, currencies: [], fiatRates: {}, diff --git a/tasks.py b/tasks.py index d44948d..e1abc9d 100644 --- a/tasks.py +++ b/tasks.py @@ -13,8 +13,8 @@ from lnbits.core.models import Payment from lnbits.helpers import get_current_extension_name from lnbits.tasks import register_invoice_listener -from . import nostr_privatekey -from .crud import get_pay_link +from .crud import get_or_create_lnurlp_settings, get_pay_link +from .models import PayLink from .nostr.event import Event @@ -35,102 +35,59 @@ async def on_invoice_paid(payment: Payment): # this webhook has already been sent return - pay_link = await get_pay_link(payment.extra.get("link", -1)) - if pay_link and pay_link.webhook_url: - async with httpx.AsyncClient() as client: - try: - r: httpx.Response = await client.post( - pay_link.webhook_url, - json={ - "payment_hash": payment.payment_hash, - "payment_request": payment.bolt11, - "amount": payment.amount, - "comment": payment.extra.get("comment"), - "lnurlp": pay_link.id, - "body": json.loads(pay_link.webhook_body) - if pay_link.webhook_body - else "", - }, - headers=json.loads(pay_link.webhook_headers) - if pay_link.webhook_headers - else None, - timeout=40, - ) - await mark_webhook_sent( - payment.payment_hash, - r.status_code, - r.is_success, - r.reason_phrase, - r.text, - ) - except Exception as ex: - logger.error(ex) - await mark_webhook_sent( - payment.payment_hash, -1, False, "Unexpected Error", str(ex) - ) + pay_link_id = payment.extra.get("link") + if not pay_link_id: + logger.error("Invoice paid. But no pay link id found.") + return - # NIP-57 - # load the zap request - nostr = payment.extra.get("nostr") - if pay_link and pay_link.zaps and nostr: - event_json = json.loads(nostr) - - def get_tag(event_json, tag): - res = [ - event_tag[1:] for event_tag in event_json["tags"] if event_tag[0] == tag - ] - return res[0] if res else None - - tags = [] - for t in ["p", "e"]: - tag = get_tag(event_json, t) - if tag: - tags.append([t, tag[0]]) - tags.append(["bolt11", payment.bolt11]) - tags.append(["description", nostr]) - zap_receipt = Event( - kind=9735, tags=tags, content=payment.extra.get("comment") or "" + pay_link = await get_pay_link(pay_link_id) + if not pay_link: + logger.error( + f"Invoice paid. But Pay link `{pay_link_id}` not found." ) - nostr_privatekey.sign_event(zap_receipt) + return - def send_zap(relay): - def send_event(_): - logger.debug(f"Sending zap to {ws.url}") - ws.send(zap_receipt.to_message()) - time.sleep(2) - ws.close() + await send_webhook(payment, pay_link) - ws = WebSocketApp(relay, on_open=send_event) - wst = Thread(target=ws.run_forever, name=f"LNURL zap {relay}") - wst.daemon = True - wst.start() - return ws, wst + if pay_link.zaps: + await send_zap(payment) - # list of all websockets - wss: List[WebSocketApp] = [] - # list of all threads for these websockets - wsts: List[Thread] = [] - # # send zap via nostrclient - # ws, wst = send_zap(f"wss://localhost:{settings.port}/nostrclient/api/v1/relay") - # wss += [ws] - # wsts += [wst] +async def send_webhook(payment: Payment, pay_link: PayLink): + if not pay_link.webhook_url: + return - # send zap receipt to relays in zap request - relays = get_tag(event_json, "relays") - if relays: - if len(relays) > 50: - relays = relays[:50] - for r in relays: - ws, wst = send_zap(r) - wss += [ws] - wsts += [wst] - - await asyncio.sleep(10) - for ws, wst in zip(wss, wsts): - logger.debug(f"Closing websocket {ws.url}") - ws.close() - wst.join() + async with httpx.AsyncClient() as client: + try: + r: httpx.Response = await client.post( + pay_link.webhook_url, + json={ + "payment_hash": payment.payment_hash, + "payment_request": payment.bolt11, + "amount": payment.amount, + "comment": payment.extra.get("comment"), + "lnurlp": pay_link.id, + "body": json.loads(pay_link.webhook_body) + if pay_link.webhook_body + else "", + }, + headers=json.loads(pay_link.webhook_headers) + if pay_link.webhook_headers + else None, + timeout=40, + ) + await mark_webhook_sent( + payment.payment_hash, + r.status_code, + r.is_success, + r.reason_phrase, + r.text, + ) + except Exception as exc: + logger.error(exc) + await mark_webhook_sent( + payment.payment_hash, -1, False, "Unexpected Error", str(exc) + ) async def mark_webhook_sent( @@ -145,3 +102,69 @@ async def mark_webhook_sent( "wh_response": text, }, ) + + +# NIP-57 - load the zap request +async def send_zap(payment: Payment): + nostr = payment.extra.get("nostr") + if not nostr: + return + + event_json = json.loads(nostr) + + def get_tag(event_json, tag): + res = [event_tag[1:] for event_tag in event_json["tags"] if event_tag[0] == tag] + return res[0] if res else None + + tags = [] + for t in ["p", "e"]: + tag = get_tag(event_json, t) + if tag: + tags.append([t, tag[0]]) + tags.append(["bolt11", payment.bolt11]) + tags.append(["description", nostr]) + zap_receipt = Event( + kind=9735, tags=tags, content=payment.extra.get("comment") or "" + ) + + settings = await get_or_create_lnurlp_settings() + settings.private_key.sign_event(zap_receipt) + + def send(relay): + def send_event(_): + logger.debug(f"Sending zap to {ws.url}") + ws.send(zap_receipt.to_message()) + time.sleep(2) + ws.close() + + ws = WebSocketApp(relay, on_open=send_event) + wst = Thread(target=ws.run_forever, name=f"LNURL zap {relay}") + wst.daemon = True + wst.start() + return ws, wst + + # list of all websockets + wss: List[WebSocketApp] = [] + # list of all threads for these websockets + wsts: List[Thread] = [] + + # # send zap via nostrclient + # ws, wst = send(f"wss://localhost:{settings.port}/nostrclient/api/v1/relay") + # wss += [ws] + # wsts += [wst] + + # send zap receipt to relays in zap request + relays = get_tag(event_json, "relays") + if relays: + if len(relays) > 50: + relays = relays[:50] + for r in relays: + ws, wst = send(r) + wss += [ws] + wsts += [wst] + + await asyncio.sleep(10) + for ws, wst in zip(wss, wsts): + logger.debug(f"Closing websocket {ws.url}") + ws.close() + wst.join() diff --git a/templates/lnurlp/index.html b/templates/lnurlp/index.html index 914b0fc..7c448ed 100644 --- a/templates/lnurlp/index.html +++ b/templates/lnurlp/index.html @@ -4,9 +4,8 @@