commit
6bce388dcd
16 changed files with 729 additions and 128 deletions
|
|
@ -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\
|
||||

|
||||
|
||||
- you can now open your LNURLp and copy the LNURL, get the shareable link or print it\
|
||||

|
||||
|
||||
|
|
|
|||
30
__init__.py
30
__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] = []
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
22
crud.py
22
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
|
||||
|
|
|
|||
26
lnurl.py
26
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
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
|
||||
{
|
||||
"repos": [
|
||||
{
|
||||
"id": "lnurlp",
|
||||
"organisation": "lnbits",
|
||||
"repository": "lnurlp"
|
||||
}
|
||||
]
|
||||
"repos": [
|
||||
{
|
||||
"id": "lnurlp",
|
||||
"organisation": "lnbits",
|
||||
"repository": "lnurlp"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;")
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
137
nostr/bech32.py
Normal file
137
nostr/bech32.py
Normal file
|
|
@ -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
|
||||
126
nostr/event.py
Normal file
126
nostr/event.py
Normal file
|
|
@ -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
|
||||
147
nostr/key.py
Normal file
147
nostr/key.py
Normal file
|
|
@ -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
|
||||
15
nostr/message_type.py
Normal file
15
nostr/message_type.py
Normal file
|
|
@ -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
|
||||
9
services.py
Normal file
9
services.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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' ||
|
||||
|
|
|
|||
74
tasks.py
74
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,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr class="text-left" :props="props">
|
||||
<q-tr class="text-left" :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th auto-width>Description</q-th>
|
||||
<q-th auto-width>Amount</q-th>
|
||||
|
|
@ -48,7 +48,8 @@
|
|||
type="a"
|
||||
:href="props.row.pay_url"
|
||||
target="_blank"
|
||||
><q-tooltip>Sharable Page</q-tooltip></q-btn>
|
||||
><q-tooltip>Sharable Page</q-tooltip></q-btn
|
||||
>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
|
|
@ -56,7 +57,8 @@
|
|||
icon="visibility"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
@click="openQrCodeDialog(props.row.id)"
|
||||
><q-tooltip>View Link</q-tooltip></q-btn>
|
||||
><q-tooltip>View Link</q-tooltip></q-btn
|
||||
>
|
||||
</q-td>
|
||||
<q-td auto-width>{{ props.row.description }}</q-td>
|
||||
<q-td auto-width>
|
||||
|
|
@ -66,10 +68,14 @@
|
|||
<span v-else>{{ props.row.min }} - {{ props.row.max }}</span>
|
||||
</q-td>
|
||||
<q-td>{{ props.row.currency || 'sat' }}</q-td>
|
||||
<q-td auto-width :class="(props.row.username) ? 'text-normal' : 'text-grey'">{{ props.row.username || 'None' }}</q-td>
|
||||
<q-td
|
||||
auto-width
|
||||
:class="(props.row.username) ? 'text-normal' : 'text-grey'"
|
||||
>{{ props.row.username || 'None' }}</q-td
|
||||
>
|
||||
<q-td>
|
||||
<q-icon v-if="props.row.webhook_url" size="14px" name="http">
|
||||
<q-tooltip>Webhook to {{ props.row.webhook_url}}</q-tooltip>
|
||||
<q-tooltip>Webhook to {{ props.row.webhook_url }}</q-tooltip>
|
||||
</q-icon>
|
||||
<q-icon
|
||||
v-if="props.row.success_text || props.row.success_url"
|
||||
|
|
@ -102,7 +108,7 @@
|
|||
icon="edit"
|
||||
color="light-blue"
|
||||
>
|
||||
<q-tooltip>Edit</q-tooltip>
|
||||
<q-tooltip>Edit</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
|
|
@ -111,7 +117,8 @@
|
|||
@click="deletePayLink(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
><q-tooltip>Delete</q-tooltip></q-btn>
|
||||
><q-tooltip>Delete</q-tooltip></q-btn
|
||||
>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
|
|
@ -125,7 +132,7 @@
|
|||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-my-none">
|
||||
{{SITE_TITLE}} LNURL-pay extension
|
||||
{{ SITE_TITLE }} LNURL-pay extension
|
||||
</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
|
|
@ -152,29 +159,30 @@
|
|||
>
|
||||
</q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.description"
|
||||
type="text"
|
||||
label="Item description *"
|
||||
>
|
||||
</q-input>
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.description"
|
||||
type="text"
|
||||
label="Item description *"
|
||||
>
|
||||
</q-input>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.username"
|
||||
type="text"
|
||||
label="Lightning Address"
|
||||
>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.username"
|
||||
type="text"
|
||||
label="Lightning Address"
|
||||
/>
|
||||
</div>
|
||||
<div class="col" style="margin-top: 10px">
|
||||
<span class="label"> @ {% raw %} {{domain}} {% endraw %} </span>
|
||||
<div class="col" style="margin-top: 10px">
|
||||
<span class="label">
|
||||
@ {% raw %} {{ domain }} {% endraw %}
|
||||
</span>
|
||||
</div>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="row q-col-gutter-sm q-mx-sm">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
|
|
@ -209,63 +217,111 @@
|
|||
v-model="formDialog.data.currency"
|
||||
:display-value="formDialog.data.currency || 'satoshis'"
|
||||
label="Currency"
|
||||
:hint="'Amounts will be converted at use-time to satoshis. ' + (formDialog.data.currency && fiatRates[formDialog.data.currency] ? `Currently 1 ${formDialog.data.currency} = ${fiatRates[formDialog.data.currency]} sat` : '')"
|
||||
:hint="'Converted to satoshis at each payment. ' + (formDialog.data.currency && fiatRates[formDialog.data.currency] ? `Currently 1 ${formDialog.data.currency} = ${fiatRates[formDialog.data.currency]} sat` : '')"
|
||||
@input="updateFiatRate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="formDialog.data.comment_chars"
|
||||
type="number"
|
||||
label="Comment maximum characters"
|
||||
hint="Tell wallets to prompt users for a comment that will be sent along with the payment. LNURLp will store the comment and send it in the webhook."
|
||||
<q-expansion-item
|
||||
group="advanced"
|
||||
icon="settings"
|
||||
label="Advanced options"
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model="formDialog.data.webhook_url"
|
||||
type="text"
|
||||
label="Webhook URL (optional)"
|
||||
hint="A URL to be called whenever this link receives a payment."
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-if="formDialog.data.webhook_url"
|
||||
v-model="formDialog.data.webhook_headers"
|
||||
type="text"
|
||||
label="Webhook headers (optional)"
|
||||
hint="Custom data as JSON string, send headers along with the webhook."
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-if="formDialog.data.webhook_url"
|
||||
v-model="formDialog.data.webhook_body"
|
||||
type="text"
|
||||
label="Webhook custom data (optional)"
|
||||
hint="Custom data as JSON string, will get posted along with webhook 'body' field."
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model="formDialog.data.success_text"
|
||||
type="text"
|
||||
label="Success message (optional)"
|
||||
hint="Will be shown to the user in his wallet after a successful payment."
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model="formDialog.data.success_url"
|
||||
type="text"
|
||||
label="Success URL (optional)"
|
||||
hint="Will be shown as a clickable link to the user in his wallet after a successful payment, appended by the payment_hash as a query string."
|
||||
>
|
||||
</q-input>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">LNURL</h5>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="formDialog.data.comment_chars"
|
||||
type="number"
|
||||
label="Comment maximum characters"
|
||||
hint="Allow the payer to attach a comment."
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model="formDialog.data.webhook_url"
|
||||
type="text"
|
||||
label="Webhook URL (optional)"
|
||||
hint="A URL to be called whenever this link receives a payment."
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-if="formDialog.data.webhook_url"
|
||||
v-model="formDialog.data.webhook_headers"
|
||||
type="text"
|
||||
label="Webhook headers (optional)"
|
||||
hint="Custom data as JSON string, send headers along with the webhook."
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-if="formDialog.data.webhook_url"
|
||||
v-model="formDialog.data.webhook_body"
|
||||
type="text"
|
||||
label="Webhook custom data (optional)"
|
||||
hint="Custom data as JSON string, will get posted along with webhook 'body' field."
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model="formDialog.data.success_text"
|
||||
type="text"
|
||||
label="Success message (optional)"
|
||||
hint="Will be shown to the user in his wallet after a successful payment."
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model="formDialog.data.success_url"
|
||||
type="text"
|
||||
label="Success URL (optional)"
|
||||
hint="Link will be shown to the sender after a successful payment."
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Nostr</h5>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<q-checkbox
|
||||
:toggle-indeterminate="false"
|
||||
dense
|
||||
v-model="formDialog.data.zaps"
|
||||
label="Enable nostr zaps"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="formDialog.data.id"
|
||||
|
|
@ -311,17 +367,22 @@
|
|||
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<br />
|
||||
<strong>Amount:</strong> {{ qrCodeDialog.data.amount }}<br />
|
||||
<span v-if="qrCodeDialog.data.currency"
|
||||
><strong>{{ qrCodeDialog.data.currency }} price:</strong> {{
|
||||
fiatRates[qrCodeDialog.data.currency] ?
|
||||
fiatRates[qrCodeDialog.data.currency] + ' sat' : 'Loading...' }}<br
|
||||
><strong>{{ qrCodeDialog.data.currency }} price:</strong>
|
||||
{{
|
||||
fiatRates[qrCodeDialog.data.currency]
|
||||
? fiatRates[qrCodeDialog.data.currency] + ' sat'
|
||||
: 'Loading...'
|
||||
}}<br
|
||||
/></span>
|
||||
<strong>Accepts comments:</strong> {{ qrCodeDialog.data.comments }}<br />
|
||||
<strong>Accepts comments:</strong> {{ qrCodeDialog.data.comments
|
||||
}}<br />
|
||||
<strong>Dispatches webhook to:</strong> {{ qrCodeDialog.data.webhook
|
||||
}}<br />
|
||||
<strong>On success:</strong> {{ qrCodeDialog.data.success }}<br />
|
||||
<span v-if="qrCodeDialog.data.username">
|
||||
<strong>Lightning Address: </strong> {{ qrCodeDialog.data.username}}@{{domain}}
|
||||
<br/>
|
||||
<strong>Lightning Address: </strong>
|
||||
{{ qrCodeDialog.data.username }}@{{ domain }}
|
||||
<br />
|
||||
</span>
|
||||
</p>
|
||||
{% endraw %}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue