diff --git a/README.md b/README.md index cee51a1..0c6021c 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ LNURL is a range of lightning-network standards that allow us to use lightning-n 2. Use the shareable link or view the LNURLp you just created\ ![LNURLp](https://i.imgur.com/C8s1P0Q.jpg) + - you can now open your LNURLp and copy the LNURL, get the shareable link or print it\ ![view lnurlp](https://i.imgur.com/4n41S7T.jpg) diff --git a/__init__.py b/__init__.py index 4ef4c7d..4ed6d80 100644 --- a/__init__.py +++ b/__init__.py @@ -7,6 +7,28 @@ from fastapi.staticfiles import StaticFiles from lnbits.db import Database from lnbits.helpers import template_renderer from lnbits.tasks import catch_everything_and_restart +from loguru import logger + + +from .nostr.event import Event +from .nostr.key import PrivateKey, PublicKey +from environs import Env + + +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") @@ -19,10 +41,10 @@ lnurlp_static_files = [ ] lnurlp_redirect_paths = [ - { - "from_path": "/.well-known/lnurlp", - "redirect_to_path": "/api/v1/well-known", - } + { + "from_path": "/.well-known/lnurlp", + "redirect_to_path": "/api/v1/well-known", + } ] scheduled_tasks: List[asyncio.Task] = [] diff --git a/config.json b/config.json index d3e046d..8e0ee88 100644 --- a/config.json +++ b/config.json @@ -1,10 +1,6 @@ { "name": "LNURLp", "short_description": "Make reusable LNURL pay links", - "tile": "/lnurlp/static/image/lnurl-pay.png", - "contributors": [ - "arcbtc", - "eillarra", - "fiatjaf" - ] + "tile": "/lnurlp/static/image/lnurl-pay.png", + "contributors": ["arcbtc", "eillarra", "fiatjaf", "callebtc"] } diff --git a/crud.py b/crud.py index aeb78d0..4104b37 100644 --- a/crud.py +++ b/crud.py @@ -5,6 +5,7 @@ from lnbits.helpers import urlsafe_short_hash from . import db # , maindb from .models import CreatePayLinkData, PayLink +from .services import check_lnaddress_format # from loguru import logger @@ -15,9 +16,8 @@ async def check_lnaddress_update(username: str, id: str) -> bool: "SELECT username FROM lnurlp.pay_links WHERE username = ? AND id = ?", (username, id), ) - if len(row) > 1: - assert False, "Username already exists. Try a different one." - return + if row: + raise Exception("Username already exists. Try a different one.") else: return True @@ -28,19 +28,11 @@ async def check_lnaddress_not_exists(username: str) -> bool: "SELECT username FROM lnurlp.pay_links WHERE username = ?", (username,) ) if row: - assert False, "Username already exists. Try a different one." + raise Exception("Username already exists. Try a different one.") else: return True -async def check_lnaddress_format(username: str) -> bool: - # check username complies with lnaddress specification - if not re.match("^[a-z0-9-_.]{3,15}$", username): - assert False, "Only letters a-z0-9-_. allowed, min 3 and max 15 characters!" - return - return True - - async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink: if data.username: await check_lnaddress_format(data.username) @@ -66,10 +58,11 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink: comment_chars, currency, fiat_base_multiplier, - username + username, + zaps ) - VALUES (?, ?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( link_id, @@ -86,6 +79,7 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink: data.currency, data.fiat_base_multiplier, data.username, + data.zaps, ), ) assert result diff --git a/lnurl.py b/lnurl.py index 471c66b..2907363 100644 --- a/lnurl.py +++ b/lnurl.py @@ -11,6 +11,8 @@ from . import lnurlp_ext from .crud import increment_pay_link, get_pay_link, get_address_data from loguru import logger from urllib.parse import urlparse +import json +from . import nostr_publickey @lnurlp_ext.get( @@ -47,15 +49,15 @@ async def api_lnurl_callback( min = link.min * 1000 max = link.max * 1000 - amount_received = amount - if amount_received < min: + amount = amount + if amount < min: return LnurlErrorResponse( - reason=f"Amount {amount_received} is smaller than minimum {min}." + reason=f"Amount {amount} is smaller than minimum {min}." ).dict() - elif amount_received > max: + elif amount > max: return LnurlErrorResponse( - reason=f"Amount {amount_received} is greater than maximum {max}." + reason=f"Amount {amount} is greater than maximum {max}." ).dict() comment = request.query_params.get("comment") @@ -77,14 +79,21 @@ async def api_lnurl_callback( if comment: extra["comment"] = (comment,) + # nip 57 + nostr = request.query_params.get("nostr") + if nostr: + extra["nostr"] = nostr # put it here for later publishing in tasks.py + if lnaddress and link.username and link.domain: extra["lnaddress"] = f"{link.username}@{link.domain}" payment_hash, payment_request = await create_invoice( wallet_id=link.wallet, - amount=int(amount_received / 1000), + amount=int(amount / 1000), memo=link.description, - unhashed_description=link.lnurlpay_metadata.encode(), + unhashed_description=nostr.encode() + if nostr # we take the zap request as the description instead of the LNURL metadata if present + else link.lnurlpay_metadata.encode(), extra=extra, ) @@ -136,4 +145,7 @@ async def api_lnurl_response(request: Request, link_id, lnaddress=False): if link.comment_chars > 0: params["commentAllowed"] = link.comment_chars + if link.zaps: + params["allowsNostr"] = True + params["nostrPubkey"] = nostr_publickey.hex() return params diff --git a/manifest.json b/manifest.json index 508a576..cc0c3d2 100644 --- a/manifest.json +++ b/manifest.json @@ -1,10 +1,9 @@ - { - "repos": [ - { - "id": "lnurlp", - "organisation": "lnbits", - "repository": "lnurlp" - } - ] + "repos": [ + { + "id": "lnurlp", + "organisation": "lnbits", + "repository": "lnurlp" + } + ] } diff --git a/migrations.py b/migrations.py index 879bec3..705b55f 100644 --- a/migrations.py +++ b/migrations.py @@ -150,6 +150,13 @@ async def m006_redux(db): async def m007_add_lnaddress_username(db): """ - Add headers and body to webhooks + Add Lightning address to pay links """ await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN username TEXT;") + + +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;") diff --git a/models.py b/models.py index 1b51960..d3f8cd3 100644 --- a/models.py +++ b/models.py @@ -24,6 +24,7 @@ class CreatePayLinkData(BaseModel): success_url: str = Query(None) fiat_base_multiplier: int = Query(100, ge=1) username: str = Query(None) + zaps: bool = Query(False) class PayLink(BaseModel): @@ -34,6 +35,7 @@ class PayLink(BaseModel): served_meta: int served_pr: int username: Optional[str] + zaps: Optional[bool] domain: Optional[str] webhook_url: Optional[str] webhook_headers: Optional[str] diff --git a/nostr/bech32.py b/nostr/bech32.py new file mode 100644 index 0000000..b068de7 --- /dev/null +++ b/nostr/bech32.py @@ -0,0 +1,137 @@ +# Copyright (c) 2017, 2020 Pieter Wuille +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +"""Reference implementation for Bech32/Bech32m and segwit addresses.""" + + +from enum import Enum + +class Encoding(Enum): + """Enumeration type to list the various supported encodings.""" + BECH32 = 1 + BECH32M = 2 + +CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" +BECH32M_CONST = 0x2bc830a3 + +def bech32_polymod(values): + """Internal function that computes the Bech32 checksum.""" + generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3] + chk = 1 + for value in values: + top = chk >> 25 + chk = (chk & 0x1ffffff) << 5 ^ value + for i in range(5): + chk ^= generator[i] if ((top >> i) & 1) else 0 + return chk + + +def bech32_hrp_expand(hrp): + """Expand the HRP into values for checksum computation.""" + return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp] + + +def bech32_verify_checksum(hrp, data): + """Verify a checksum given HRP and converted data characters.""" + const = bech32_polymod(bech32_hrp_expand(hrp) + data) + if const == 1: + return Encoding.BECH32 + if const == BECH32M_CONST: + return Encoding.BECH32M + return None + +def bech32_create_checksum(hrp, data, spec): + """Compute the checksum values given HRP and data.""" + values = bech32_hrp_expand(hrp) + data + const = BECH32M_CONST if spec == Encoding.BECH32M else 1 + polymod = bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ const + return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)] + + +def bech32_encode(hrp, data, spec): + """Compute a Bech32 string given HRP and data values.""" + combined = data + bech32_create_checksum(hrp, data, spec) + return hrp + '1' + ''.join([CHARSET[d] for d in combined]) + +def bech32_decode(bech): + """Validate a Bech32/Bech32m string, and determine HRP and data.""" + if ((any(ord(x) < 33 or ord(x) > 126 for x in bech)) or + (bech.lower() != bech and bech.upper() != bech)): + return (None, None, None) + bech = bech.lower() + pos = bech.rfind('1') + if pos < 1 or pos + 7 > len(bech) or len(bech) > 90: + return (None, None, None) + if not all(x in CHARSET for x in bech[pos+1:]): + return (None, None, None) + hrp = bech[:pos] + data = [CHARSET.find(x) for x in bech[pos+1:]] + spec = bech32_verify_checksum(hrp, data) + if spec is None: + return (None, None, None) + return (hrp, data[:-6], spec) + +def convertbits(data, frombits, tobits, pad=True): + """General power-of-2 base conversion.""" + acc = 0 + bits = 0 + ret = [] + maxv = (1 << tobits) - 1 + max_acc = (1 << (frombits + tobits - 1)) - 1 + for value in data: + if value < 0 or (value >> frombits): + return None + acc = ((acc << frombits) | value) & max_acc + bits += frombits + while bits >= tobits: + bits -= tobits + ret.append((acc >> bits) & maxv) + if pad: + if bits: + ret.append((acc << (tobits - bits)) & maxv) + elif bits >= frombits or ((acc << (tobits - bits)) & maxv): + return None + return ret + + +def decode(hrp, addr): + """Decode a segwit address.""" + hrpgot, data, spec = bech32_decode(addr) + if hrpgot != hrp: + return (None, None) + decoded = convertbits(data[1:], 5, 8, False) + if decoded is None or len(decoded) < 2 or len(decoded) > 40: + return (None, None) + if data[0] > 16: + return (None, None) + if data[0] == 0 and len(decoded) != 20 and len(decoded) != 32: + return (None, None) + if data[0] == 0 and spec != Encoding.BECH32 or data[0] != 0 and spec != Encoding.BECH32M: + return (None, None) + return (data[0], decoded) + + +def encode(hrp, witver, witprog): + """Encode a segwit address.""" + spec = Encoding.BECH32 if witver == 0 else Encoding.BECH32M + ret = bech32_encode(hrp, [witver] + convertbits(witprog, 8, 5), spec) + if decode(hrp, ret) == (None, None): + return None + return ret diff --git a/nostr/event.py b/nostr/event.py new file mode 100644 index 0000000..b903e0e --- /dev/null +++ b/nostr/event.py @@ -0,0 +1,126 @@ +import time +import json +from dataclasses import dataclass, field +from enum import IntEnum +from typing import List +from secp256k1 import PublicKey +from hashlib import sha256 + +from .message_type import ClientMessageType + + +class EventKind(IntEnum): + SET_METADATA = 0 + TEXT_NOTE = 1 + RECOMMEND_RELAY = 2 + CONTACTS = 3 + ENCRYPTED_DIRECT_MESSAGE = 4 + DELETE = 5 + + +@dataclass +class Event: + content: str = None + public_key: str = None + created_at: int = None + kind: int = EventKind.TEXT_NOTE + tags: List[List[str]] = field( + default_factory=list + ) # Dataclasses require special handling when the default value is a mutable type + signature: str = None + + def __post_init__(self): + if self.content is not None and not isinstance(self.content, str): + # DMs initialize content to None but all other kinds should pass in a str + raise TypeError("Argument 'content' must be of type str") + + if self.created_at is None: + self.created_at = int(time.time()) + + @staticmethod + def serialize( + public_key: str, created_at: int, kind: int, tags: List[List[str]], content: str + ) -> bytes: + data = [0, public_key, created_at, kind, tags, content] + data_str = json.dumps(data, separators=(",", ":"), ensure_ascii=False) + return data_str.encode() + + @staticmethod + def compute_id( + public_key: str, created_at: int, kind: int, tags: List[List[str]], content: str + ): + return sha256( + Event.serialize(public_key, created_at, kind, tags, content) + ).hexdigest() + + @property + def id(self) -> str: + # Always recompute the id to reflect the up-to-date state of the Event + return Event.compute_id( + self.public_key, self.created_at, self.kind, self.tags, self.content + ) + + def add_pubkey_ref(self, pubkey: str): + """Adds a reference to a pubkey as a 'p' tag""" + self.tags.append(["p", pubkey]) + + def add_event_ref(self, event_id: str): + """Adds a reference to an event_id as an 'e' tag""" + self.tags.append(["e", event_id]) + + def verify(self) -> bool: + pub_key = PublicKey( + bytes.fromhex("02" + self.public_key), True + ) # add 02 for schnorr (bip340) + return pub_key.schnorr_verify( + bytes.fromhex(self.id), bytes.fromhex(self.signature), None, raw=True + ) + + def to_message(self) -> str: + return json.dumps( + [ + ClientMessageType.EVENT, + { + "id": self.id, + "pubkey": self.public_key, + "created_at": self.created_at, + "kind": self.kind, + "tags": self.tags, + "content": self.content, + "sig": self.signature, + }, + ] + ) + + +@dataclass +class EncryptedDirectMessage(Event): + recipient_pubkey: str = None + cleartext_content: str = None + reference_event_id: str = None + + def __post_init__(self): + if self.content is not None: + self.cleartext_content = self.content + self.content = None + + if self.recipient_pubkey is None: + raise Exception("Must specify a recipient_pubkey.") + + self.kind = EventKind.ENCRYPTED_DIRECT_MESSAGE + super().__post_init__() + + # Must specify the DM recipient's pubkey in a 'p' tag + self.add_pubkey_ref(self.recipient_pubkey) + + # Optionally specify a reference event (DM) this is a reply to + if self.reference_event_id is not None: + self.add_event_ref(self.reference_event_id) + + @property + def id(self) -> str: + if self.content is None: + raise Exception( + "EncryptedDirectMessage `id` is undefined until its message is encrypted and stored in the `content` field" + ) + return super().id diff --git a/nostr/key.py b/nostr/key.py new file mode 100644 index 0000000..4adb0b9 --- /dev/null +++ b/nostr/key.py @@ -0,0 +1,147 @@ +import secrets +import base64 +import secp256k1 +from cffi import FFI +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.primitives import padding +from hashlib import sha256 + +from .event import EncryptedDirectMessage, Event, EventKind +from . import bech32 + + +class PublicKey: + def __init__(self, raw_bytes: bytes) -> None: + self.raw_bytes = raw_bytes + + def bech32(self) -> str: + converted_bits = bech32.convertbits(self.raw_bytes, 8, 5) + return bech32.bech32_encode("npub", converted_bits, bech32.Encoding.BECH32) + + def hex(self) -> str: + return self.raw_bytes.hex() + + def verify_signed_message_hash(self, hash: str, sig: str) -> bool: + pk = secp256k1.PublicKey(b"\x02" + self.raw_bytes, True) + return pk.schnorr_verify(bytes.fromhex(hash), bytes.fromhex(sig), None, True) + + @classmethod + def from_npub(cls, npub: str): + """Load a PublicKey from its bech32/npub form""" + hrp, data, spec = bech32.bech32_decode(npub) + raw_public_key = bech32.convertbits(data, 5, 8)[:-1] + return cls(bytes(raw_public_key)) + + +class PrivateKey: + def __init__(self, raw_secret: bytes = None) -> None: + if not raw_secret is None: + self.raw_secret = raw_secret + else: + self.raw_secret = secrets.token_bytes(32) + + sk = secp256k1.PrivateKey(self.raw_secret) + self.public_key = PublicKey(sk.pubkey.serialize()[1:]) + + @classmethod + def from_nsec(cls, nsec: str): + """Load a PrivateKey from its bech32/nsec form""" + hrp, data, spec = bech32.bech32_decode(nsec) + raw_secret = bech32.convertbits(data, 5, 8)[:-1] + return cls(bytes(raw_secret)) + + def bech32(self) -> str: + converted_bits = bech32.convertbits(self.raw_secret, 8, 5) + return bech32.bech32_encode("nsec", converted_bits, bech32.Encoding.BECH32) + + def hex(self) -> str: + return self.raw_secret.hex() + + def tweak_add(self, scalar: bytes) -> bytes: + sk = secp256k1.PrivateKey(self.raw_secret) + return sk.tweak_add(scalar) + + def compute_shared_secret(self, public_key_hex: str) -> bytes: + pk = secp256k1.PublicKey(bytes.fromhex("02" + public_key_hex), True) + return pk.ecdh(self.raw_secret, hashfn=copy_x) + + def encrypt_message(self, message: str, public_key_hex: str) -> str: + padder = padding.PKCS7(128).padder() + padded_data = padder.update(message.encode()) + padder.finalize() + + iv = secrets.token_bytes(16) + cipher = Cipher( + algorithms.AES(self.compute_shared_secret(public_key_hex)), modes.CBC(iv) + ) + + encryptor = cipher.encryptor() + encrypted_message = encryptor.update(padded_data) + encryptor.finalize() + + return f"{base64.b64encode(encrypted_message).decode()}?iv={base64.b64encode(iv).decode()}" + + def encrypt_dm(self, dm: EncryptedDirectMessage) -> None: + dm.content = self.encrypt_message( + message=dm.cleartext_content, public_key_hex=dm.recipient_pubkey + ) + + def decrypt_message(self, encoded_message: str, public_key_hex: str) -> str: + encoded_data = encoded_message.split("?iv=") + encoded_content, encoded_iv = encoded_data[0], encoded_data[1] + + iv = base64.b64decode(encoded_iv) + cipher = Cipher( + algorithms.AES(self.compute_shared_secret(public_key_hex)), modes.CBC(iv) + ) + encrypted_content = base64.b64decode(encoded_content) + + decryptor = cipher.decryptor() + decrypted_message = decryptor.update(encrypted_content) + decryptor.finalize() + + unpadder = padding.PKCS7(128).unpadder() + unpadded_data = unpadder.update(decrypted_message) + unpadder.finalize() + + return unpadded_data.decode() + + def sign_message_hash(self, hash: bytes) -> str: + sk = secp256k1.PrivateKey(self.raw_secret) + sig = sk.schnorr_sign(hash, None, raw=True) + return sig.hex() + + def sign_event(self, event: Event) -> None: + if event.kind == EventKind.ENCRYPTED_DIRECT_MESSAGE and event.content is None: + self.encrypt_dm(event) + if event.public_key is None: + event.public_key = self.public_key.hex() + event.signature = self.sign_message_hash(bytes.fromhex(event.id)) + + def __eq__(self, other): + return self.raw_secret == other.raw_secret + + +def mine_vanity_key(prefix: str = None, suffix: str = None) -> PrivateKey: + if prefix is None and suffix is None: + raise ValueError("Expected at least one of 'prefix' or 'suffix' arguments") + + while True: + sk = PrivateKey() + if ( + prefix is not None + and not sk.public_key.bech32()[5 : 5 + len(prefix)] == prefix + ): + continue + if suffix is not None and not sk.public_key.bech32()[-len(suffix) :] == suffix: + continue + break + + return sk + + +ffi = FFI() + + +@ffi.callback( + "int (unsigned char *, const unsigned char *, const unsigned char *, void *)" +) +def copy_x(output, x32, y32, data): + ffi.memmove(output, x32, 32) + return 1 diff --git a/nostr/message_type.py b/nostr/message_type.py new file mode 100644 index 0000000..3f5206b --- /dev/null +++ b/nostr/message_type.py @@ -0,0 +1,15 @@ +class ClientMessageType: + EVENT = "EVENT" + REQUEST = "REQ" + CLOSE = "CLOSE" + +class RelayMessageType: + EVENT = "EVENT" + NOTICE = "NOTICE" + END_OF_STORED_EVENTS = "EOSE" + + @staticmethod + def is_valid(type: str) -> bool: + if type == RelayMessageType.EVENT or type == RelayMessageType.NOTICE or type == RelayMessageType.END_OF_STORED_EVENTS: + return True + return False \ No newline at end of file diff --git a/services.py b/services.py new file mode 100644 index 0000000..641ffce --- /dev/null +++ b/services.py @@ -0,0 +1,9 @@ +import re + + +async def check_lnaddress_format(username: str) -> bool: + # check username complies with lnaddress specification + if not re.match("^[a-z0-9-_.]{3,15}$", username): + assert False, "Only letters a-z0-9-_. allowed, min 3 and max 15 characters!" + return + return True diff --git a/static/js/index.js b/static/js/index.js index 8edc055..e9951f1 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -40,7 +40,9 @@ new Vue({ formDialog: { show: false, fixedAmount: true, - data: {} + data: { + zaps:false + } }, qrCodeDialog: { show: false, @@ -140,7 +142,8 @@ new Vue({ 'success_url', 'comment_chars', 'currency', - 'username' + 'username', + 'zaps' ), (value, key) => (key === 'webhook_url' || diff --git a/tasks.py b/tasks.py index ea01e04..af736d5 100644 --- a/tasks.py +++ b/tasks.py @@ -8,8 +8,16 @@ from lnbits.core.crud import update_payment_extra from lnbits.core.models import Payment from lnbits.helpers import get_current_extension_name from lnbits.tasks import register_invoice_listener - +from websocket import WebSocketApp +from lnbits.settings import settings from .crud import get_pay_link +from threading import Thread +from . import nostr_privatekey +from typing import List +import time + +from .nostr.event import Event +from .nostr.key import PrivateKey, PublicKey async def wait_for_paid_invoices(): @@ -63,11 +71,73 @@ async def on_invoice_paid(payment: Payment): payment.payment_hash, -1, False, "Unexpected Error", str(ex) ) + # 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 "" + ) + nostr_privatekey.sign_event(zap_receipt) + + 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() + + 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_zap(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_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 def mark_webhook_sent( payment_hash: str, status: int, is_success: bool, reason_phrase="", text="" ) -> None: - await update_payment_extra( payment_hash, { diff --git a/templates/lnurlp/index.html b/templates/lnurlp/index.html index 1ecd490..4b8202e 100644 --- a/templates/lnurlp/index.html +++ b/templates/lnurlp/index.html @@ -26,7 +26,7 @@ > {% raw %} @@ -125,7 +132,7 @@
- {{SITE_TITLE}} LNURL-pay extension + {{ SITE_TITLE }} LNURL-pay extension
@@ -152,29 +159,30 @@ > - + filled + dense + v-model.trim="formDialog.data.description" + type="text" + label="Item description *" + > +
- +
-
-   @ {% raw %} {{domain}} {% endraw %} +
+ +   @ {% raw %} {{ domain }} {% endraw %} +
-
-
+
- - - - - - - - + + +
LNURL
+
+
+ + +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ + +
+
+
+ +
Nostr
+
+
+ +
+
+
+
+
ID: {{ qrCodeDialog.data.id }}
Amount: {{ qrCodeDialog.data.amount }}
{{ qrCodeDialog.data.currency }} price: {{ - fiatRates[qrCodeDialog.data.currency] ? - fiatRates[qrCodeDialog.data.currency] + ' sat' : 'Loading...' }}
{{ qrCodeDialog.data.currency }} price: + {{ + fiatRates[qrCodeDialog.data.currency] + ? fiatRates[qrCodeDialog.data.currency] + ' sat' + : 'Loading...' + }}
- Accepts comments: {{ qrCodeDialog.data.comments }}
+ Accepts comments: {{ qrCodeDialog.data.comments + }}
Dispatches webhook to: {{ qrCodeDialog.data.webhook }}
On success: {{ qrCodeDialog.data.success }}
- Lightning Address: {{ qrCodeDialog.data.username}}@{{domain}} -
+ Lightning Address: + {{ qrCodeDialog.data.username }}@{{ domain }} +

{% endraw %}