This commit is contained in:
Tiago Vasconcelos 2024-09-10 12:49:53 +01:00
commit 1d98dd1223
29 changed files with 3073 additions and 221 deletions

10
.github/workflows/lint.yml vendored Normal file
View file

@ -0,0 +1,10 @@
name: lint
on:
push:
branches:
- main
pull_request:
jobs:
lint:
uses: lnbits/lnbits/.github/workflows/lint.yml@dev

View file

@ -1,10 +1,9 @@
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
- 'v[0-9]+.[0-9]+.[0-9]+'
jobs:
release:
runs-on: ubuntu-latest
steps:
@ -34,12 +33,12 @@ jobs:
- name: Create pull request in extensions repo
env:
GH_TOKEN: ${{ secrets.EXT_GITHUB }}
repo_name: "${{ github.event.repository.name }}"
tag: "${{ github.ref_name }}"
branch: "update-${{ github.event.repository.name }}-${{ github.ref_name }}"
title: "[UPDATE] ${{ github.event.repository.name }} to ${{ github.ref_name }}"
body: "https://github.com/lnbits/${{ github.event.repository.name }}/releases/${{ github.ref_name }}"
archive: "https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip"
repo_name: '${{ github.event.repository.name }}'
tag: '${{ github.ref_name }}'
branch: 'update-${{ github.event.repository.name }}-${{ github.ref_name }}'
title: '[UPDATE] ${{ github.event.repository.name }} to ${{ github.ref_name }}'
body: 'https://github.com/lnbits/${{ github.event.repository.name }}/releases/${{ github.ref_name }}'
archive: 'https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip'
run: |
cd lnbits-extensions
git checkout -b $branch

3
.gitignore vendored
View file

@ -1 +1,4 @@
__pycache__
node_modules
.mypy_cache
.venv

12
.prettierrc Normal file
View file

@ -0,0 +1,12 @@
{
"semi": false,
"arrowParens": "avoid",
"insertPragma": false,
"printWidth": 80,
"proseWrap": "preserve",
"singleQuote": true,
"trailingComma": "none",
"useTabs": false,
"bracketSameLine": false,
"bracketSpacing": false
}

47
Makefile Normal file
View file

@ -0,0 +1,47 @@
all: format check
format: prettier black ruff
check: mypy pyright checkblack checkruff checkprettier
prettier:
poetry run ./node_modules/.bin/prettier --write .
pyright:
poetry run ./node_modules/.bin/pyright
mypy:
poetry run mypy .
black:
poetry run black .
ruff:
poetry run ruff check . --fix
checkruff:
poetry run ruff check .
checkprettier:
poetry run ./node_modules/.bin/prettier --check .
checkblack:
poetry run black --check .
checkeditorconfig:
editorconfig-checker
test:
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest
install-pre-commit-hook:
@echo "Installing pre-commit hook to git"
@echo "Uninstall the hook with poetry run pre-commit uninstall"
poetry run pre-commit install
pre-commit:
poetry run pre-commit run --all-files
checkbundle:
@echo "skipping checkbundle"

View file

@ -3,12 +3,11 @@ from typing import List
from fastapi import APIRouter
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import create_permanent_unique_task
db = Database("ext_lnurlp")
from .crud import db
from .tasks import wait_for_paid_invoices
from .views import lnurlp_generic_router
from .views_api import lnurlp_api_router
from .views_lnurl import lnurlp_lnurl_router
lnurlp_static_files = [
{
@ -26,15 +25,9 @@ lnurlp_redirect_paths = [
lnurlp_ext: APIRouter = APIRouter(prefix="/lnurlp", tags=["lnurlp"])
def lnurlp_renderer():
return template_renderer(["lnurlp/templates"])
from .lnurl import * # noqa: F401,F403
from .tasks import wait_for_paid_invoices
from .views import * # noqa: F401,F403
from .views_api import * # noqa: F401,F403
lnurlp_ext.include_router(lnurlp_generic_router)
lnurlp_ext.include_router(lnurlp_api_router)
lnurlp_ext.include_router(lnurlp_lnurl_router)
scheduled_tasks: List[asyncio.Task] = []
@ -43,6 +36,19 @@ def lnurlp_stop():
for task in scheduled_tasks:
task.cancel()
def lnurlp_start():
from lnbits.tasks import create_permanent_unique_task
task = create_permanent_unique_task("lnurlp", wait_for_paid_invoices)
scheduled_tasks.append(task)
__all__ = [
"lnurlp_ext",
"lnurlp_static_files",
"lnurlp_redirect_paths",
"lnurlp_stop",
"lnurlp_start",
"db",
]

11
crud.py
View file

@ -1,11 +1,13 @@
from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash, insert_query, update_query
from lnbits.db import Database
from lnbits.helpers import insert_query, update_query, urlsafe_short_hash
from . import db
from .models import CreatePayLinkData, LnurlpSettings, PayLink
from .nostr.key import PrivateKey
db = Database("ext_lnurlp")
async def get_or_create_lnurlp_settings() -> LnurlpSettings:
row = await db.fetchone("SELECT * FROM lnurlp.settings LIMIT 1")
@ -14,8 +16,7 @@ async def get_or_create_lnurlp_settings() -> LnurlpSettings:
else:
settings = LnurlpSettings(nostr_private_key=PrivateKey().hex())
await db.execute(
insert_query("lnurlp.settings", settings),
(*settings.dict().values(),)
insert_query("lnurlp.settings", settings), (*settings.dict().values(),)
)
return settings
@ -23,7 +24,7 @@ async def get_or_create_lnurlp_settings() -> LnurlpSettings:
async def update_lnurlp_settings(settings: LnurlpSettings) -> LnurlpSettings:
await db.execute(
update_query("lnurlp.settings", settings, where=""),
(*settings.dict().values(),)
(*settings.dict().values(),),
)
return settings

View file

@ -1,2 +1 @@
Create a static LNURLp or LNaddress people can use to pay.

View file

@ -58,7 +58,8 @@ async def m004_fiat_base_multiplier(db):
remember to multiply by 100 when we use it to convert to Dollars.
"""
await db.execute(
"ALTER TABLE lnurlp.pay_links ADD COLUMN fiat_base_multiplier INTEGER DEFAULT 1;"
"ALTER TABLE lnurlp.pay_links "
"ADD COLUMN fiat_base_multiplier INTEGER DEFAULT 1;"
)

View file

@ -1,15 +1,13 @@
import json
from sqlite3 import Row
from typing import Dict, Optional
from urllib.parse import ParseResult, urlparse, urlunparse
from typing import Optional
from fastapi import Request
from fastapi.param_functions import Query
from lnurl import encode as lnurl_encode
from lnurl.types import LnurlPayMetadata
from pydantic import BaseModel
from lnbits.lnurl import encode as lnurl_encode
from .helpers import parse_nostr_private_key
from .nostr.key import PrivateKey
@ -72,29 +70,13 @@ class PayLink(BaseModel):
return cls(**data)
def lnurl(self, req: Request) -> str:
url = str(req.url_for("lnurlp.api_lnurl_response", link_id=self.id))
# Check if url is .onion and change to http
if urlparse(url).netloc.endswith(".onion"):
url = req.url_for("lnurlp.api_lnurl_response", link_id=self.id)
url_str = str(url)
if url.netloc.endswith(".onion"):
# change url string scheme to http
url = url.replace("https://", "http://")
url_str = url_str.replace("https://", "http://")
return lnurl_encode(url)
def success_action(self, payment_hash: str) -> Optional[Dict]:
if self.success_url:
url: ParseResult = urlparse(self.success_url)
# qs = parse_qs(url.query)
# setattr(qs, "payment_hash", payment_hash)
# url = url._replace(query=urlencode(qs, doseq=True))
return {
"tag": "url",
"description": self.success_text or "~",
"url": urlunparse(url),
}
elif self.success_text:
return {"tag": "message", "message": self.success_text}
else:
return None
return lnurl_encode(url_str)
@property
def lnurlpay_metadata(self) -> LnurlPayMetadata:

View file

@ -23,21 +23,25 @@
from enum import Enum
class Encoding(Enum):
"""Enumeration type to list the various supported encodings."""
BECH32 = 1
BECH32M = 2
CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
BECH32M_CONST = 0x2bc830a3
BECH32M_CONST = 0x2BC830A3
def bech32_polymod(values):
"""Internal function that computes the Bech32 checksum."""
generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
generator = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3]
chk = 1
for value in values:
top = chk >> 25
chk = (chk & 0x1ffffff) << 5 ^ value
chk = (chk & 0x1FFFFFF) << 5 ^ value
for i in range(5):
chk ^= generator[i] if ((top >> i) & 1) else 0
return chk
@ -57,26 +61,29 @@ def bech32_verify_checksum(hrp, data):
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
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])
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)):
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')
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 :]):
@ -88,6 +95,7 @@ def bech32_decode(bech):
return (None, None, None)
return (hrp, data[:-6], spec)
def convertbits(data, frombits, tobits, pad=True):
"""General power-of-2 base conversion."""
acc = 0
@ -114,6 +122,7 @@ def convertbits(data, frombits, tobits, pad=True):
def decode(hrp, addr):
"""Decode a segwit address."""
hrpgot, data, spec = bech32_decode(addr)
assert data, "Invalid bech32 string"
if hrpgot != hrp:
return (None, None)
decoded = convertbits(data[1:], 5, 8, False)
@ -123,7 +132,12 @@ def decode(hrp, addr):
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:
if (
data[0] == 0
and spec != Encoding.BECH32
or data[0] != 0
and spec != Encoding.BECH32M
):
return (None, None)
return (data[0], decoded)
@ -131,7 +145,9 @@ def decode(hrp, addr):
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)
bits = convertbits(witprog, 8, 5)
assert bits, "Invalid witness program"
ret = bech32_encode(hrp, [witver, *bits], spec)
if decode(hrp, ret) == (None, None):
return None
return ret

View file

@ -1,10 +1,11 @@
import time
import json
import time
from dataclasses import dataclass, field
from enum import IntEnum
from typing import List
from secp256k1 import PublicKey
from hashlib import sha256
from typing import List, Optional
from secp256k1 import PublicKey
from .message_type import ClientMessageType
@ -20,14 +21,14 @@ class EventKind(IntEnum):
@dataclass
class Event:
content: str = None
public_key: str = None
created_at: int = None
content: Optional[str] = None
public_key: Optional[str] = None
created_at: Optional[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
signature: Optional[str] = None
def __post_init__(self):
if self.content is not None and not isinstance(self.content, str):
@ -56,6 +57,9 @@ class Event:
@property
def id(self) -> str:
# Always recompute the id to reflect the up-to-date state of the Event
assert self.public_key, "Event public key is missing"
assert self.created_at, "Event created_at is missing"
assert self.content, "Event content is missing"
return Event.compute_id(
self.public_key, self.created_at, self.kind, self.tags, self.content
)
@ -69,9 +73,11 @@ class Event:
self.tags.append(["e", event_id])
def verify(self) -> bool:
assert self.public_key, "Event public key is missing"
pub_key = PublicKey(
bytes.fromhex("02" + self.public_key), True
) # add 02 for schnorr (bip340)
assert self.signature, "Event signature is missing"
return pub_key.schnorr_verify(
bytes.fromhex(self.id), bytes.fromhex(self.signature), None, raw=True
)
@ -95,9 +101,9 @@ class Event:
@dataclass
class EncryptedDirectMessage(Event):
recipient_pubkey: str = None
cleartext_content: str = None
reference_event_id: str = None
recipient_pubkey: Optional[str] = None
cleartext_content: Optional[str] = None
reference_event_id: Optional[str] = None
def __post_init__(self):
if self.content is not None:
@ -121,6 +127,7 @@ class EncryptedDirectMessage(Event):
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"
"EncryptedDirectMessage `id` is undefined until its "
"message is encrypted and stored in the `content` field"
)
return super().id

View file

@ -1,13 +1,14 @@
import secrets
import base64
import secrets
from typing import Optional
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 cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from .event import EncryptedDirectMessage, Event, EventKind
from . import bech32
from .event import EncryptedDirectMessage, EventKind
class PublicKey:
@ -21,33 +22,41 @@ class PublicKey:
def hex(self) -> str:
return self.raw_bytes.hex()
def verify_signed_message_hash(self, hash: str, sig: str) -> bool:
def verify_signed_message_hash(self, message_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)
return pk.schnorr_verify(
bytes.fromhex(message_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]
assert data, "Invalid npub"
bits = bech32.convertbits(data, 5, 8)
assert bits, "Invalid npub"
raw_public_key = bits[:-1]
return cls(bytes(raw_public_key))
class PrivateKey:
def __init__(self, raw_secret: bytes = None) -> None:
if not raw_secret is None:
def __init__(self, raw_secret: Optional[bytes] = None) -> None:
if raw_secret is not None:
self.raw_secret = raw_secret
else:
self.raw_secret = secrets.token_bytes(32)
sk = secp256k1.PrivateKey(self.raw_secret)
assert sk.pubkey, "Invalid public"
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]
bits = bech32.convertbits(data, 5, 8)
assert bits, "Invalid nsec"
raw_secret = bits[:-1]
return cls(bytes(raw_secret))
def bech32(self) -> str:
@ -77,11 +86,13 @@ class PrivateKey:
encryptor = cipher.encryptor()
encrypted_message = encryptor.update(padded_data) + encryptor.finalize()
return f"{base64.b64encode(encrypted_message).decode()}?iv={base64.b64encode(iv).decode()}"
msg = base64.b64encode(encrypted_message).decode()
return f"{msg}?iv={base64.b64encode(iv).decode()}"
def encrypt_dm(self, dm: EncryptedDirectMessage) -> None:
assert dm.recipient_pubkey, "Recipient public key must be set"
dm.content = self.encrypt_message(
message=dm.cleartext_content, public_key_hex=dm.recipient_pubkey
message=dm.cleartext_content or "", public_key_hex=dm.recipient_pubkey
)
def decrypt_message(self, encoded_message: str, public_key_hex: str) -> str:
@ -102,12 +113,12 @@ class PrivateKey:
return unpadded_data.decode()
def sign_message_hash(self, hash: bytes) -> str:
def sign_message_hash(self, message_hash: bytes) -> str:
sk = secp256k1.PrivateKey(self.raw_secret)
sig = sk.schnorr_sign(hash, None, raw=True)
sig = sk.schnorr_sign(message_hash, None, raw=True)
return sig.hex()
def sign_event(self, event: Event) -> None:
def sign_event(self, event: EncryptedDirectMessage) -> None:
if event.kind == EventKind.ENCRYPTED_DIRECT_MESSAGE and event.content is None:
self.encrypt_dm(event)
if event.public_key is None:
@ -118,7 +129,9 @@ class PrivateKey:
return self.raw_secret == other.raw_secret
def mine_vanity_key(prefix: str = None, suffix: str = None) -> PrivateKey:
def mine_vanity_key(
prefix: Optional[str] = None, suffix: Optional[str] = None
) -> PrivateKey:
if prefix is None and suffix is None:
raise ValueError("Expected at least one of 'prefix' or 'suffix' arguments")

View file

@ -3,13 +3,18 @@ class ClientMessageType:
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:
def is_valid(relay_type: str) -> bool:
if (
relay_type == RelayMessageType.EVENT
or relay_type == RelayMessageType.NOTICE
or relay_type == RelayMessageType.END_OF_STORED_EVENTS
):
return True
return False

59
package-lock.json generated Normal file
View file

@ -0,0 +1,59 @@
{
"name": "lnurlp",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lnurlp",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"prettier": "^3.2.5",
"pyright": "^1.1.358"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/prettier": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
"integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pyright": {
"version": "1.1.372",
"resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.372.tgz",
"integrity": "sha512-S0XYmTQWK+ha9FTIWviNk91UnbD569wPUCNEltSqtHeTJhbHj5z3LkOKiqXAOvn72BBfylcgpQqyQHsocmQtiQ==",
"bin": {
"pyright": "index.js",
"pyright-langserver": "langserver.index.js"
},
"engines": {
"node": ">=14.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
}
}
}

15
package.json Normal file
View file

@ -0,0 +1,15 @@
{
"name": "lnurlp",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"prettier": "^3.2.5",
"pyright": "^1.1.358"
}
}

2510
poetry.lock generated Normal file

File diff suppressed because it is too large Load diff

97
pyproject.toml Normal file
View file

@ -0,0 +1,97 @@
[tool.poetry]
name = "lnbits-lnurlp"
version = "0.0.0"
description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = ["Alan Bits <alan@lnbits.com>"]
[tool.poetry.dependencies]
python = "^3.10 | ^3.9"
lnbits = "*"
[tool.poetry.group.dev.dependencies]
black = "^24.3.0"
pytest-asyncio = "^0.21.0"
pytest = "^7.3.2"
mypy = "^1.5.1"
pre-commit = "^3.2.2"
ruff = "^0.3.2"
types-cffi = "^1.16.0.20240331"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.mypy]
# exclude = "(nostr/*)"
[[tool.mypy.overrides]]
module = [
"lnbits.*",
"lnurl.*",
"loguru.*",
"fastapi.*",
"pydantic.*",
"pyqrcode.*",
"shortuuid.*",
"httpx.*",
"websocket.*",
"secp256k1.*",
]
ignore_missing_imports = "True"
[tool.pytest.ini_options]
log_cli = false
testpaths = [
"tests"
]
[tool.black]
line-length = 88
[tool.ruff]
# Same as Black. + 10% rule of black
line-length = 88
# exclude = [
# "nostr",
# ]
[tool.ruff.lint]
# Enable:
# F - pyflakes
# E - pycodestyle errors
# W - pycodestyle warnings
# I - isort
# A - flake8-builtins
# C - mccabe
# N - naming
# UP - pyupgrade
# RUF - ruff
# B - bugbear
select = ["F", "E", "W", "I", "A", "C", "N", "UP", "RUF", "B"]
ignore = ["C901"]
# Allow autofix for all enabled rules (when `--fix`) is provided.
fixable = ["ALL"]
unfixable = []
# Allow unused variables when underscore-prefixed.
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
# needed for pydantic
[tool.ruff.lint.pep8-naming]
classmethod-decorators = [
"root_validator",
]
# Ignore unused imports in __init__.py files.
# [tool.ruff.lint.extend-per-file-ignores]
# "__init__.py" = ["F401", "F403"]
# [tool.ruff.lint.mccabe]
# max-complexity = 10
[tool.ruff.lint.flake8-bugbear]
# Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`.
extend-immutable-calls = [
"fastapi.Depends",
"fastapi.Query",
]

View file

@ -1,9 +0,0 @@
import re
async def check_lnaddress_format(username: str) -> bool:
# check username complies with lnaddress specification
if not re.match("^[a-z0-9-_.]{3,64}$", username):
assert False, "Only letters a-z0-9-_. allowed, min 3 and max 64 characters!"
return
return True

View file

@ -33,9 +33,9 @@ new Vue({
return {
settings: [
{
"type": "str",
"description": "Nostr private key used to zap",
"name": "nostr_private_key",
type: 'str',
description: 'Nostr private key used to zap',
name: 'nostr_private_key'
}
],
domain: window.location.host,

View file

@ -15,7 +15,7 @@ from lnbits.tasks import register_invoice_listener
from .crud import get_or_create_lnurlp_settings, get_pay_link
from .models import PayLink
from .nostr.event import Event
from .nostr.event import EncryptedDirectMessage
async def wait_for_paid_invoices():
@ -40,24 +40,20 @@ async def on_invoice_paid(payment: Payment):
logger.error("Invoice paid. But no pay link id found.")
return
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."
)
logger.error(f"Invoice paid. But Pay link `{pay_link_id}` not found.")
return
if pay_link.zaps:
zap_receipt = await send_zap(payment)
pay_link.zap_receipt = zap_receipt
await send_webhook(payment, pay_link)
await send_webhook(
payment, pay_link, zap_receipt.to_message() if zap_receipt else None
)
async def send_webhook(payment: Payment, pay_link: PayLink):
async def send_webhook(payment: Payment, pay_link: PayLink, zap_receipt=None):
if not pay_link.webhook_url:
return
@ -72,14 +68,18 @@ async def send_webhook(payment: Payment, pay_link: PayLink):
"comment": payment.extra.get("comment"),
"webhook_data": payment.extra.get("webhook_data") or "",
"lnurlp": pay_link.id,
"zap_receipt": pay_link.zap_receipt,
"body": json.loads(pay_link.webhook_body)
"body": (
json.loads(pay_link.webhook_body)
if pay_link.webhook_body
else "",
else ""
),
"zap_receipt": zap_receipt or "",
},
headers=json.loads(pay_link.webhook_headers)
headers=(
json.loads(pay_link.webhook_headers)
if pay_link.webhook_headers
else None,
else None
),
timeout=40,
)
await mark_webhook_sent(
@ -129,8 +129,15 @@ async def send_zap(payment: Payment):
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 ""
pubkey = next((pk[1] for pk in tags if pk[0] == "p"), None)
assert pubkey, "Cannot create zap receipt. Recepient pubkey is missing."
zap_receipt = EncryptedDirectMessage(
kind=9735,
recipient_pubkey=pubkey,
tags=tags,
content=payment.extra.get("comment") or "",
cleartext_content=payment.extra.get("comment") or "",
)
settings = await get_or_create_lnurlp_settings()

View file

@ -4,8 +4,14 @@
<div class="col-12 col-md-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="primary" @click="formDialog.show = true">New pay link</q-btn>
<lnbits-extension-settings-btn-dialog v-if="this.g.user.admin" :endpoint="endpoint" :options="settings" />
<q-btn unelevated color="primary" @click="formDialog.show = true"
>New pay link</q-btn
>
<lnbits-extension-settings-btn-dialog
v-if="this.g.user.admin"
:endpoint="endpoint"
:options="settings"
/>
</q-card-section>
</q-card>

0
tests/__init__.py Normal file
View file

11
tests/test_init.py Normal file
View file

@ -0,0 +1,11 @@
import pytest
from fastapi import APIRouter
from .. import lnurlp_ext
# just import router and add it to a test router
@pytest.mark.asyncio
async def test_router():
router = APIRouter()
router.include_router(lnurlp_ext)

7
toc.md
View file

@ -1,22 +1,29 @@
# Terms and Conditions for LNbits Extension
## 1. Acceptance of Terms
By installing and using the LNbits extension ("Extension"), you agree to be bound by these terms and conditions ("Terms"). If you do not agree to these Terms, do not use the Extension.
## 2. License
The Extension is free and open-source software, released under [specify the FOSS license here, e.g., GPL-3.0, MIT, etc.]. You are permitted to use, copy, modify, and distribute the Extension under the terms of that license.
## 3. No Warranty
The Extension is provided "as is" and with all faults, and the developer expressly disclaims all warranties of any kind, whether express, implied, statutory, or otherwise, including but not limited to warranties of merchantability, fitness for a particular purpose, non-infringement, and any warranties arising out of course of dealing or usage of trade. No advice or information, whether oral or written, obtained from the developer or elsewhere will create any warranty not expressly stated in this Terms.
## 4. Limitation of Liability
In no event will the developer be liable to you or any third party for any direct, indirect, incidental, special, consequential, or punitive damages, including lost profit, lost revenue, loss of data, or other damages arising out of or in connection with your use of the Extension, even if the developer has been advised of the possibility of such damages. The foregoing limitation of liability shall apply to the fullest extent permitted by law in the applicable jurisdiction.
## 5. Modification of Terms
The developer reserves the right to modify these Terms at any time. You are advised to review these Terms periodically for any changes. Changes to these Terms are effective when they are posted on the appropriate location within or associated with the Extension.
## 6. General Provisions
If any provision of these Terms is held to be invalid or unenforceable, that provision will be enforced to the maximum extent permissible, and the other provisions of these Terms will remain in full force and effect. These Terms constitute the entire agreement between you and the developer regarding the use of the Extension.
## 7. Contact Information
If you have any questions about these Terms, please contact the developer at [developer's contact information].

View file

@ -1,27 +1,33 @@
from http import HTTPStatus
from fastapi import Depends, Request
from fastapi import APIRouter, Depends, Request
from fastapi.templating import Jinja2Templates
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from lnbits.helpers import template_renderer
from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from . import lnurlp_ext, lnurlp_renderer
from .crud import get_pay_link
lnurlp_generic_router = APIRouter()
def lnurlp_renderer():
return template_renderer(["lnurlp/templates"])
templates = Jinja2Templates(directory="templates")
@lnurlp_ext.get("/", response_class=HTMLResponse)
@lnurlp_generic_router.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
return lnurlp_renderer().TemplateResponse(
"lnurlp/index.html", {"request": request, "user": user.dict()}
)
@lnurlp_ext.get("/link/{link_id}", response_class=HTMLResponse)
@lnurlp_generic_router.get("/link/{link_id}", response_class=HTMLResponse)
async def display(request: Request, link_id):
link = await get_pay_link(link_id)
if not link:
@ -32,7 +38,7 @@ async def display(request: Request, link_id):
return lnurlp_renderer().TemplateResponse("lnurlp/display.html", ctx)
@lnurlp_ext.get("/print/{link_id}", response_class=HTMLResponse)
@lnurlp_generic_router.get("/print/{link_id}", response_class=HTMLResponse)
async def print_qr(request: Request, link_id):
link = await get_pay_link(link_id)
if not link:

View file

@ -1,21 +1,25 @@
import json
import re
from http import HTTPStatus
from typing import Optional
from fastapi import Depends, Query, Request
from fastapi import APIRouter, Depends, Query, Request
from lnbits.core.crud import get_user, get_wallet
from lnbits.core.models import WalletTypeInfo
from lnbits.decorators import (
check_admin,
get_key_type,
require_admin_key,
require_invoice_key,
)
from lnbits.utils.exchange_rates import currencies, get_fiat_rate_satoshis
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
from starlette.exceptions import HTTPException
from lnbits.core.crud import get_user, get_wallet
from lnbits.decorators import WalletTypeInfo, check_admin, get_key_type, require_admin_key, require_invoice_key
from lnbits.utils.exchange_rates import currencies, get_fiat_rate_satoshis
from . import lnurlp_ext
from .crud import (
create_pay_link,
delete_lnurlp_settings,
delete_pay_link,
get_address_data,
get_or_create_lnurlp_settings,
get_pay_link,
get_pay_link_by_username,
@ -23,26 +27,18 @@ from .crud import (
update_lnurlp_settings,
update_pay_link,
)
from .services import check_lnaddress_format
from .helpers import parse_nostr_private_key
from .lnurl import api_lnurl_response
from .models import CreatePayLinkData, LnurlpSettings
# redirected from /.well-known/lnurlp
@lnurlp_ext.get("/api/v1/well-known/{username}")
async def lnaddress(username: str, request: Request):
address_data = await get_address_data(username)
assert address_data, "User not found"
return await api_lnurl_response(request, address_data.id, webhook_data=None)
lnurlp_api_router = APIRouter()
@lnurlp_ext.get("/api/v1/currencies")
@lnurlp_api_router.get("/api/v1/currencies")
async def api_list_currencies_available():
return list(currencies.keys())
@lnurlp_ext.get("/api/v1/links", status_code=HTTPStatus.OK)
@lnurlp_api_router.get("/api/v1/links", status_code=HTTPStatus.OK)
async def api_links(
req: Request,
wallet: WalletTypeInfo = Depends(get_key_type),
@ -60,14 +56,17 @@ async def api_links(
for link in await get_pay_links(wallet_ids)
]
except LnurlInvalidUrl:
except LnurlInvalidUrl as exc:
raise HTTPException(
status_code=HTTPStatus.UPGRADE_REQUIRED,
detail="LNURLs need to be delivered over a publically accessible `https` domain or Tor.",
)
detail=(
"LNURLs need to be delivered over a publicly "
"accessible `https` domain or Tor onion."
),
) from exc
@lnurlp_ext.get("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
@lnurlp_api_router.get("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
async def api_link_retrieve(
r: Request, link_id: str, key_info: WalletTypeInfo = Depends(require_invoice_key)
):
@ -80,7 +79,10 @@ async def api_link_retrieve(
link_wallet = await get_wallet(link.wallet)
if link_wallet.user != key_info.wallet.user:
# admins are allowed to read paylinks beloging to regular users
user = await get_user(key_info.wallet.user)
admin_user = user.admin if user else False
if not admin_user and link_wallet and link_wallet.user != key_info.wallet.user:
raise HTTPException(
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
)
@ -93,11 +95,12 @@ async def check_username_exists(username: str):
if prev_link:
raise HTTPException(
detail="Username already taken.",
status_code=HTTPStatus.BAD_REQUEST,
status_code=HTTPStatus.CONFLICT,
)
@lnurlp_ext.post("/api/v1/links", status_code=HTTPStatus.CREATED)
@lnurlp_ext.put("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
@lnurlp_api_router.post("/api/v1/links", status_code=HTTPStatus.CREATED)
@lnurlp_api_router.put("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
async def api_link_create_or_update(
data: CreatePayLinkData,
request: Request,
@ -119,20 +122,20 @@ async def api_link_create_or_update(
if data.webhook_headers:
try:
json.loads(data.webhook_headers)
except ValueError:
except ValueError as exc:
raise HTTPException(
detail="Invalid JSON in webhook_headers.",
status_code=HTTPStatus.BAD_REQUEST,
)
) from exc
if data.webhook_body:
try:
json.loads(data.webhook_body)
except ValueError:
except ValueError as exc:
raise HTTPException(
detail="Invalid JSON in webhook_body.",
status_code=HTTPStatus.BAD_REQUEST,
)
) from exc
# database only allows int4 entries for min and max. For fiat currencies,
# we multiply by data.fiat_base_multiplier (usually 100) to save the value in cents.
@ -140,18 +143,21 @@ async def api_link_create_or_update(
data.min *= data.fiat_base_multiplier
data.max *= data.fiat_base_multiplier
if data.success_url and data.success_url != "" and not data.success_url.startswith("https://"):
if (
data.success_url
and data.success_url != ""
and not data.success_url.startswith("https://")
):
raise HTTPException(
detail="Success URL must be secure https://...",
status_code=HTTPStatus.BAD_REQUEST,
)
if data.username:
try:
await check_lnaddress_format(data.username)
except AssertionError as ex:
if data.username and not re.match("^[a-z0-9-_.]{1,210}$", data.username):
raise HTTPException(
detail=f"Invalid username: {ex}", status_code=HTTPStatus.BAD_REQUEST
detail=f"Invalid username: {data.username}. "
"Only letters a-z0-9-_. allowed, min 1 and max 210 characters!",
status_code=HTTPStatus.BAD_REQUEST,
)
# if wallet is not provided, use the wallet of the key
@ -164,7 +170,10 @@ async def api_link_create_or_update(
detail="Wallet does not exist.", status_code=HTTPStatus.FORBIDDEN
)
if new_wallet.user != key_info.wallet.user:
# admins are allowed to create/edit paylinks beloging to regular users
user = await get_user(key_info.wallet.user)
admin_user = user.admin if user else False
if not admin_user and new_wallet.user != key_info.wallet.user:
raise HTTPException(
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
)
@ -191,7 +200,7 @@ async def api_link_create_or_update(
return {**link.dict(), "lnurl": link.lnurl(request)}
@lnurlp_ext.delete("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
@lnurlp_api_router.delete("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
async def api_link_delete(link_id: str, wallet: WalletTypeInfo = Depends(get_key_type)):
link = await get_pay_link(link_id)
@ -200,7 +209,10 @@ async def api_link_delete(link_id: str, wallet: WalletTypeInfo = Depends(get_key
detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND
)
if link.wallet != wallet.wallet.id:
# admins are allowed to delete paylinks beloging to regular users
user = await get_user(wallet.wallet.user)
admin_user = user.admin if user else False
if not admin_user and link.wallet != wallet.wallet.id:
raise HTTPException(
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
)
@ -209,7 +221,7 @@ async def api_link_delete(link_id: str, wallet: WalletTypeInfo = Depends(get_key
return {"success": True}
@lnurlp_ext.get("/api/v1/rate/{currency}", status_code=HTTPStatus.OK)
@lnurlp_api_router.get("/api/v1/rate/{currency}", status_code=HTTPStatus.OK)
async def api_check_fiat_rate(currency):
try:
rate = await get_fiat_rate_satoshis(currency)
@ -219,22 +231,22 @@ async def api_check_fiat_rate(currency):
return {"rate": rate}
@lnurlp_ext.get("/api/v1/settings", dependencies=[Depends(check_admin)])
@lnurlp_api_router.get("/api/v1/settings", dependencies=[Depends(check_admin)])
async def api_get_or_create_settings() -> LnurlpSettings:
return await get_or_create_lnurlp_settings()
@lnurlp_ext.put("/api/v1/settings", dependencies=[Depends(check_admin)])
@lnurlp_api_router.put("/api/v1/settings", dependencies=[Depends(check_admin)])
async def api_update_settings(data: LnurlpSettings) -> LnurlpSettings:
try:
parse_nostr_private_key(data.nostr_private_key)
except Exception:
except Exception as exc:
raise HTTPException(
detail="Invalid Nostr private key.", status_code=HTTPStatus.BAD_REQUEST
)
) from exc
return await update_lnurlp_settings(data)
@lnurlp_ext.delete("/api/v1/settings", dependencies=[Depends(check_admin)])
@lnurlp_api_router.delete("/api/v1/settings", dependencies=[Depends(check_admin)])
async def api_delete_settings() -> None:
await delete_lnurlp_settings()

View file

@ -1,21 +1,31 @@
from http import HTTPStatus
from typing import Optional
from fastapi import Query, Request
from lnurl import LnurlErrorResponse, LnurlPayActionResponse, LnurlPayResponse
from starlette.exceptions import HTTPException
from typing import Optional, Union
from fastapi import APIRouter, HTTPException, Query, Request
from lnbits.core.services import create_invoice
from lnbits.utils.exchange_rates import get_fiat_rate_satoshis
from lnurl import LnurlErrorResponse, LnurlPayActionResponse, LnurlPayResponse
from lnurl.models import MessageAction, UrlAction
from lnurl.types import (
ClearnetUrl,
DebugUrl,
LightningInvoice,
Max144Str,
MilliSatoshi,
OnionUrl,
)
from pydantic import parse_obj_as
from . import lnurlp_ext
from .crud import (
get_address_data,
get_or_create_lnurlp_settings,
increment_pay_link,
)
lnurlp_lnurl_router = APIRouter()
@lnurlp_ext.get(
@lnurlp_lnurl_router.get(
"/api/v1/lnurl/cb/{link_id}",
status_code=HTTPStatus.OK,
name="lnurlp.api_lnurl_callback",
@ -31,23 +41,25 @@ async def api_lnurl_callback(
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist."
)
min, max = link.min, link.max
mininum = link.min
maximum = link.max
rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1
if link.currency:
# allow some fluctuation (as the fiat price may have changed between the calls)
min = rate * 995 * link.min
max = rate * 1010 * link.max
mininum = rate * 995 * link.min
maximum = rate * 1010 * link.max
else:
min = link.min * 1000
max = link.max * 1000
mininum = link.min * 1000
maximum = link.max * 1000
amount = amount
if amount < min:
if amount < mininum:
return LnurlErrorResponse(
reason=f"Amount {amount} is smaller than minimum {min}."
).dict()
elif amount > max:
elif amount > maximum:
return LnurlErrorResponse(
reason=f"Amount {amount} is greater than maximum {max}."
).dict()
@ -72,7 +84,7 @@ async def api_lnurl_callback(
}
if comment:
extra["comment"] = (comment,)
extra["comment"] = comment
if webhook_data:
extra["webhook_data"] = webhook_data
@ -88,7 +100,7 @@ async def api_lnurl_callback(
# we take the zap request as the description instead of the metadata if present
unhashed_description = nostr.encode() if nostr else link.lnurlpay_metadata.encode()
payment_hash, payment_request = await create_invoice(
_, payment_request = await create_invoice(
wallet_id=link.wallet,
amount=int(amount / 1000),
memo=link.description,
@ -96,23 +108,29 @@ async def api_lnurl_callback(
extra=extra,
)
success_action = link.success_action(payment_hash)
if success_action:
resp = LnurlPayActionResponse(
pr=payment_request, success_action=success_action, routes=[] # type: ignore
action: Optional[Union[MessageAction, UrlAction]] = None
if link.success_url:
url = parse_obj_as(
Union[DebugUrl, OnionUrl, ClearnetUrl], # type: ignore
str(link.success_url),
)
else:
resp = LnurlPayActionResponse(pr=payment_request, routes=[]) # type: ignore
desc = parse_obj_as(Max144Str, link.success_text)
action = UrlAction(url=url, description=desc)
elif link.success_text:
message = parse_obj_as(Max144Str, link.success_text)
action = MessageAction(message=message)
invoice = parse_obj_as(LightningInvoice, LightningInvoice(payment_request))
resp = LnurlPayActionResponse(pr=invoice, successAction=action, routes=[])
return resp.dict()
@lnurlp_ext.get(
@lnurlp_lnurl_router.get(
"/api/v1/lnurl/{link_id}", # Backwards compatibility for old LNURLs / QR codes
status_code=HTTPStatus.OK,
name="lnurlp.api_lnurl_response.deprecated",
)
@lnurlp_ext.get(
@lnurlp_lnurl_router.get(
"/{link_id}",
status_code=HTTPStatus.OK,
name="lnurlp.api_lnurl_response",
@ -132,11 +150,15 @@ async def api_lnurl_response(
url = url.include_query_params(webhook_data=webhook_data)
link.domain = request.url.netloc
callback_url = parse_obj_as(
Union[DebugUrl, OnionUrl, ClearnetUrl], # type: ignore
str(url),
)
resp = LnurlPayResponse(
callback=str(url),
min_sendable=round(link.min * rate) * 1000, # type: ignore
max_sendable=round(link.max * rate) * 1000, # type: ignore
callback=callback_url,
minSendable=MilliSatoshi(round(link.min * rate) * 1000),
maxSendable=MilliSatoshi(round(link.max * rate) * 1000),
metadata=link.lnurlpay_metadata,
)
params = resp.dict()
@ -149,3 +171,11 @@ async def api_lnurl_response(
params["allowsNostr"] = True
params["nostrPubkey"] = settings.public_key
return params
# redirected from /.well-known/lnurlp
@lnurlp_lnurl_router.get("/api/v1/well-known/{username}")
async def lnaddress(username: str, request: Request):
address_data = await get_address_data(username)
assert address_data, "User not found"
return await api_lnurl_response(request, address_data.id, webhook_data=None)