diff --git a/lnbits/core/__init__.py b/lnbits/core/__init__.py index a35c07a95..7b1cb816f 100644 --- a/lnbits/core/__init__.py +++ b/lnbits/core/__init__.py @@ -8,6 +8,7 @@ from .views.extension_api import extension_router # this compat is needed for usermanager extension from .views.generic import generic_router +from .views.lnurl_api import lnurl_router from .views.node_api import node_router, public_node_router, super_node_router from .views.payment_api import payment_router from .views.public_api import public_router @@ -38,3 +39,4 @@ def init_core_routers(app: FastAPI): app.include_router(tinyurl_router) app.include_router(webpush_router) app.include_router(users_router) + app.include_router(lnurl_router) diff --git a/lnbits/core/models.py b/lnbits/core/models.py index 83214c745..be9b62f4a 100644 --- a/lnbits/core/models.py +++ b/lnbits/core/models.py @@ -1,6 +1,4 @@ import datetime -import hashlib -import hmac import json import time from dataclasses import dataclass @@ -8,14 +6,12 @@ from enum import Enum from sqlite3 import Row from typing import Callable, Dict, List, Optional -from ecdsa import SECP256k1, SigningKey from fastapi import Query +from lnurl import LnurlAuthResponse, LnurlPayResponse, LnurlWithdrawResponse from loguru import logger from pydantic import BaseModel from lnbits.db import Connection, FilterModel, FromRowModel -from lnbits.helpers import url_for -from lnbits.lnurl import encode as lnurl_encode from lnbits.settings import settings from lnbits.wallets import get_funding_source from lnbits.wallets.base import PaymentPendingStatus, PaymentStatus @@ -40,28 +36,6 @@ class Wallet(BaseWallet): def balance(self) -> int: return self.balance_msat // 1000 - @property - def withdrawable_balance(self) -> int: - from .services import fee_reserve - - return self.balance_msat - fee_reserve(self.balance_msat) - - @property - def lnurlwithdraw_full(self) -> str: - url = url_for("/withdraw", external=True, usr=self.user, wal=self.id) - try: - return lnurl_encode(url) - except Exception: - return "" - - def lnurlauth_key(self, domain: str) -> SigningKey: - hashing_key = hashlib.sha256(self.id.encode()).digest() - linking_key = hmac.digest(hashing_key, domain.encode(), "sha256") - - return SigningKey.from_string( - linking_key, curve=SECP256k1, hashfunc=hashlib.sha256 - ) - async def get_payment(self, payment_hash: str) -> Optional["Payment"]: from .crud import get_standalone_payment @@ -397,15 +371,23 @@ class DecodePayment(BaseModel): data: str -class CreateLnurl(BaseModel): - description_hash: str - callback: str +class CreateLnurlAuth(BaseModel): + auth_response: LnurlAuthResponse + + +class CreateLnurlPay(BaseModel): + pay_response: LnurlPayResponse amount: int comment: Optional[str] = None - description: Optional[str] = None unit: Optional[str] = None +class CreateLnurlWithdraw(BaseModel): + withdraw_response: LnurlWithdrawResponse + amount: int + memo: Optional[str] = None + + class CreateInvoice(BaseModel): unit: str = "sat" internal: bool = False @@ -418,7 +400,6 @@ class CreateInvoice(BaseModel): extra: Optional[dict] = None webhook: Optional[str] = None bolt11: Optional[str] = None - lnurl_callback: Optional[str] = None class CreateTopup(BaseModel): @@ -426,10 +407,6 @@ class CreateTopup(BaseModel): amount: int -class CreateLnurlAuth(BaseModel): - callback: str - - class CreateWallet(BaseModel): name: Optional[str] = None diff --git a/lnbits/core/services.py b/lnbits/core/services.py index ce4df160e..d68676518 100644 --- a/lnbits/core/services.py +++ b/lnbits/core/services.py @@ -1,17 +1,13 @@ -import asyncio import datetime import json import time -from io import BytesIO from pathlib import Path from typing import Dict, List, Optional, Tuple, TypedDict -from urllib.parse import parse_qs, urlparse from uuid import UUID, uuid4 -import httpx from bolt11 import decode as bolt11_decode from cryptography.hazmat.primitives import serialization -from fastapi import Depends, WebSocket +from fastapi import WebSocket from loguru import logger from passlib.context import CryptContext from py_vapid import Vapid @@ -20,13 +16,8 @@ from py_vapid.utils import b64urlencode from lnbits.core.db import db from lnbits.db import Connection from lnbits.decorators import ( - WalletTypeInfo, check_user_extension_access, - require_admin_key, ) -from lnbits.helpers import url_for -from lnbits.lnurl import LnurlErrorResponse -from lnbits.lnurl import decode as decode_lnurl from lnbits.settings import ( EditableSettings, SuperSettings, @@ -475,140 +466,6 @@ async def check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat): ) -async def redeem_lnurl_withdraw( - wallet_id: str, - lnurl_request: str, - memo: Optional[str] = None, - extra: Optional[Dict] = None, - wait_seconds: int = 0, - conn: Optional[Connection] = None, -) -> None: - if not lnurl_request: - return None - - res = {} - - headers = {"User-Agent": settings.user_agent} - async with httpx.AsyncClient(headers=headers) as client: - lnurl = decode_lnurl(lnurl_request) - r = await client.get(str(lnurl)) - res = r.json() - - try: - _, payment_request = await create_invoice( - wallet_id=wallet_id, - amount=int(res["maxWithdrawable"] / 1000), - memo=memo or res["defaultDescription"] or "", - extra=extra, - conn=conn, - ) - except Exception: - logger.warning( - f"failed to create invoice on redeem_lnurl_withdraw " - f"from {lnurl}. params: {res}" - ) - return None - - if wait_seconds: - await asyncio.sleep(wait_seconds) - - params = {"k1": res["k1"], "pr": payment_request} - - try: - params["balanceNotify"] = url_for( - f"/withdraw/notify/{urlparse(lnurl_request).netloc}", - external=True, - wal=wallet_id, - ) - except Exception: - pass - - headers = {"User-Agent": settings.user_agent} - async with httpx.AsyncClient(headers=headers) as client: - try: - await client.get(res["callback"], params=params) - except Exception: - pass - - -async def perform_lnurlauth( - callback: str, - wallet: WalletTypeInfo = Depends(require_admin_key), -) -> Optional[LnurlErrorResponse]: - cb = urlparse(callback) - - k1 = bytes.fromhex(parse_qs(cb.query)["k1"][0]) - - key = wallet.wallet.lnurlauth_key(cb.netloc) - - def int_to_bytes_suitable_der(x: int) -> bytes: - """for strict DER we need to encode the integer with some quirks""" - b = x.to_bytes((x.bit_length() + 7) // 8, "big") - - if len(b) == 0: - # ensure there's at least one byte when the int is zero - return bytes([0]) - - if b[0] & 0x80 != 0: - # ensure it doesn't start with a 0x80 and so it isn't - # interpreted as a negative number - return bytes([0]) + b - - return b - - def encode_strict_der(r: int, s: int, order: int): - # if s > order/2 verification will fail sometimes - # so we must fix it here see: - # https://github.com/indutny/elliptic/blob/e71b2d9359c5fe9437fbf46f1f05096de447de57/lib/elliptic/ec/index.js#L146-L147 - if s > order // 2: - s = order - s - - # now we do the strict DER encoding copied from - # https://github.com/KiriKiri/bip66 (without any checks) - r_temp = int_to_bytes_suitable_der(r) - s_temp = int_to_bytes_suitable_der(s) - - r_len = len(r_temp) - s_len = len(s_temp) - sign_len = 6 + r_len + s_len - - signature = BytesIO() - signature.write(0x30.to_bytes(1, "big", signed=False)) - signature.write((sign_len - 2).to_bytes(1, "big", signed=False)) - signature.write(0x02.to_bytes(1, "big", signed=False)) - signature.write(r_len.to_bytes(1, "big", signed=False)) - signature.write(r_temp) - signature.write(0x02.to_bytes(1, "big", signed=False)) - signature.write(s_len.to_bytes(1, "big", signed=False)) - signature.write(s_temp) - - return signature.getvalue() - - sig = key.sign_digest_deterministic(k1, sigencode=encode_strict_der) - - headers = {"User-Agent": settings.user_agent} - async with httpx.AsyncClient(headers=headers) as client: - assert key.verifying_key, "LNURLauth verifying_key does not exist" - r = await client.get( - callback, - params={ - "k1": k1.hex(), - "key": key.verifying_key.to_string("compressed").hex(), - "sig": sig.hex(), - }, - ) - try: - resp = json.loads(r.text) - if resp["status"] == "OK": - return None - - return LnurlErrorResponse(reason=resp["reason"]) - except (KeyError, json.decoder.JSONDecodeError): - return LnurlErrorResponse( - reason=r.text[:200] + "..." if len(r.text) > 200 else r.text - ) - - async def check_transaction_status( wallet_id: str, payment_hash: str, conn: Optional[Connection] = None ) -> PaymentStatus: diff --git a/lnbits/core/templates/core/wallet.html b/lnbits/core/templates/core/wallet.html index 7b1b2fd3e..038a2285c 100644 --- a/lnbits/core/templates/core/wallet.html +++ b/lnbits/core/templates/core/wallet.html @@ -126,26 +126,6 @@ - {% if wallet.lnurlwithdraw_full %} - - - - - - -

-
-
-
- - {% endif %} - - -

- is requesting an invoice: -

{% if LNBITS_DENOMINATION != 'sats' %} {% else %} {% endif %} @@ -337,11 +311,7 @@ :disable="receive.data.amount == null || receive.data.amount <= 0" type="submit" > - - +
+
@@ -448,7 +419,7 @@

- Authenticate with ? + Authenticate with ?

@@ -458,9 +429,7 @@ data will be shared with .

-

- Your public key for is: -

+

Your public key for is:

@@ -481,10 +450,56 @@
+
+ +

+ is requesting
+ between + + and + + +

+ +
+

+
+
+
+ +
+
+
+ Withdraw + +
+
+

- is requesting + is requesting @@ -497,9 +512,7 @@

- + is requesting
between and @@ -516,7 +529,7 @@

@@ -536,17 +549,17 @@

Wallet: return account.wallets[0] -@api_router.get("/api/v1/lnurlscan/{code}") -async def api_lnurlscan( - code: str, wallet: WalletTypeInfo = Depends(require_invoice_key) -): - try: - url = str(lnurl_decode(code)) - domain = urlparse(url).netloc - except Exception as exc: - # parse internet identifier (user@domain.com) - name_domain = code.split("@") - if len(name_domain) == 2 and len(name_domain[1].split(".")) >= 2: - name, domain = name_domain - url = ( - ("http://" if domain.endswith(".onion") else "https://") - + domain - + "/.well-known/lnurlp/" - + name - ) - # will proceed with these values - else: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, detail="invalid lnurl" - ) from exc - - # params is what will be returned to the client - params: Dict = {"domain": domain} - - if "tag=login" in url: - params.update(kind="auth") - params.update(callback=url) # with k1 already in it - - lnurlauth_key = wallet.wallet.lnurlauth_key(domain) - assert lnurlauth_key.verifying_key - params.update(pubkey=lnurlauth_key.verifying_key.to_string("compressed").hex()) - else: - headers = {"User-Agent": settings.user_agent} - async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client: - r = await client.get(url, timeout=5) - r.raise_for_status() - if r.is_error: - raise HTTPException( - status_code=HTTPStatus.SERVICE_UNAVAILABLE, - detail={"domain": domain, "message": "failed to get parameters"}, - ) - - try: - data = json.loads(r.text) - except json.decoder.JSONDecodeError as exc: - raise HTTPException( - status_code=HTTPStatus.SERVICE_UNAVAILABLE, - detail={ - "domain": domain, - "message": f"got invalid response '{r.text[:200]}'", - }, - ) from exc - - try: - tag: str = data.get("tag") - params.update(**data) - if tag == "channelRequest": - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail={ - "domain": domain, - "kind": "channel", - "message": "unsupported", - }, - ) - elif tag == "withdrawRequest": - params.update(kind="withdraw") - params.update(fixed=data["minWithdrawable"] == data["maxWithdrawable"]) - - # callback with k1 already in it - parsed_callback: ParseResult = urlparse(data["callback"]) - qs: Dict = parse_qs(parsed_callback.query) - qs["k1"] = data["k1"] - - # balanceCheck/balanceNotify - if "balanceCheck" in data: - params.update(balanceCheck=data["balanceCheck"]) - - # format callback url and send to client - parsed_callback = parsed_callback._replace( - query=urlencode(qs, doseq=True) - ) - params.update(callback=urlunparse(parsed_callback)) - elif tag == "payRequest": - params.update(kind="pay") - params.update(fixed=data["minSendable"] == data["maxSendable"]) - - params.update( - description_hash=hashlib.sha256( - data["metadata"].encode() - ).hexdigest() - ) - metadata = json.loads(data["metadata"]) - for [k, v] in metadata: - if k == "text/plain": - params.update(description=v) - if k in ("image/jpeg;base64", "image/png;base64"): - data_uri = f"data:{k},{v}" - params.update(image=data_uri) - if k in ("text/email", "text/identifier"): - params.update(targetUser=v) - params.update(commentAllowed=data.get("commentAllowed", 0)) - - except KeyError as exc: - raise HTTPException( - status_code=HTTPStatus.SERVICE_UNAVAILABLE, - detail={ - "domain": domain, - "message": f"lnurl JSON response invalid: {exc}", - }, - ) from exc - - return params - - -@api_router.post("/api/v1/lnurlauth") -async def api_perform_lnurlauth( - data: CreateLnurlAuth, wallet: WalletTypeInfo = Depends(require_admin_key) -): - err = await perform_lnurlauth(data.callback, wallet=wallet) - if err: - raise HTTPException( - status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail=err.reason - ) - return "" - - @api_router.get("/api/v1/currencies") async def api_list_currencies_available() -> List[str]: return allowed_currencies() diff --git a/lnbits/core/views/lnurl_api.py b/lnbits/core/views/lnurl_api.py new file mode 100644 index 000000000..ed2a13db7 --- /dev/null +++ b/lnbits/core/views/lnurl_api.py @@ -0,0 +1,156 @@ +import json +from http import HTTPStatus + +from fastapi import ( + APIRouter, + Depends, +) +from fastapi.exceptions import HTTPException +from lnurl import ( + LnurlPayActionResponse, + LnurlSuccessResponse, + execute_login, + execute_pay_request, +) +from lnurl import handle as lnurl_handle +from lnurl.core import execute_withdraw +from lnurl.exceptions import InvalidLnurl + +from lnbits.core.models import CreateLnurlAuth, CreateLnurlPay, CreateLnurlWithdraw +from lnbits.core.services import create_invoice, pay_invoice +from lnbits.decorators import WalletTypeInfo, require_admin_key +from lnbits.settings import settings +from lnbits.utils.exchange_rates import fiat_amount_as_satoshis + +lnurl_router = APIRouter(tags=["LNURL"]) + + +@lnurl_router.get("/api/v1/lnurlscan/{code}") +@lnurl_router.get("/lnurl/api/v1/scan/{code}") +async def api_lnurl_scan(code: str) -> dict: + try: + handle = await lnurl_handle(code, user_agent=settings.user_agent) + return handle.dict() + except InvalidLnurl as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Invalid LNURL", + ) from exc + except Exception as exc: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Error processing LNURL", + ) from exc + + +@lnurl_router.post("/lnurl/api/v1/auth") +async def api_lnurl_auth( + data: CreateLnurlAuth, key_type: WalletTypeInfo = Depends(require_admin_key) +): + try: + res = await execute_login(data.auth_response, key_type.wallet.adminkey) + assert isinstance( + res, LnurlSuccessResponse + ), "unexpected response from execute_login" + return res + except Exception as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Failed to auth, try new LNURL", + ) from exc + + +@lnurl_router.post("/lnurl/api/v1/withdraw") +async def api_lnurl_withdraw( + data: CreateLnurlWithdraw, key_type: WalletTypeInfo = Depends(require_admin_key) +): + try: + payment_hash, payment_request = await create_invoice( + wallet_id=key_type.wallet.id, + amount=data.amount / 1000, + memo=data.memo or "", + extra={"tag": "lnurl-withdraw"}, + ) + res = await execute_withdraw(data.withdraw_response, payment_request) + assert isinstance( + res, LnurlSuccessResponse + ), "unexpected response from execute_withdraw" + return {"payment_hash": payment_hash} + except Exception as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Failed to withdraw: {exc}", + ) from exc + + +@lnurl_router.post("/api/v1/payments/lnurl") +@lnurl_router.post("/lnurl/api/v1/pay") +async def api_lnurl_pay( + data: CreateLnurlPay, key_type: WalletTypeInfo = Depends(require_admin_key) +): + amount_msat = data.amount + if data.unit and data.unit != "sat": + amount_msat = await fiat_amount_as_satoshis(data.amount, data.unit) + # no msat precision, why? + amount_msat = int(amount_msat // 1000) * 1000 + + description = None + metadata = json.loads(data.pay_response.metadata) + for x in metadata: + if x[0] == "text/plain": + description = x[1] + + if not description: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="description required on LNURL pay_response.", + ) + + # pay + # params.update( + # description_hash=hashlib.sha256( + # data["metadata"].encode() + # ).hexdigest() + # ) + # metadata = json.loads(data["metadata"]) + # for [k, v] in metadata: + # if k == "text/plain": + # params.update(description=v) + # if k in ("image/jpeg;base64", "image/png;base64"): + # data_uri = f"data:{k},{v}" + # params.update(image=data_uri) + # if k in ("text/email", "text/identifier"): + # params.update(targetUser=v) + + try: + res = await execute_pay_request(data.pay_response, str(amount_msat)) + assert isinstance( + res, LnurlPayActionResponse + ), "unexpected response from execute_pay_request" + except Exception as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Failed to fetch invoice: {exc}", + ) from exc + + extra: dict = {} + if res.success_action: + extra["success_action"] = res.success_action.json() + if data.comment: + extra["comment"] = data.comment + if data.unit and data.unit != "sat": + extra["fiat_currency"] = data.unit + extra["fiat_amount"] = data.amount / 1000 + + payment_hash = await pay_invoice( + wallet_id=key_type.wallet.id, + payment_request=res.pr, + description=description, + extra=extra, + ) + return { + "success_action": res.success_action, + "payment_hash": payment_hash, + # maintain backwards compatibility with API clients: + "checking_id": payment_hash, + } diff --git a/lnbits/core/views/payment_api.py b/lnbits/core/views/payment_api.py index c16f86858..e4a4dfaec 100644 --- a/lnbits/core/views/payment_api.py +++ b/lnbits/core/views/payment_api.py @@ -1,12 +1,8 @@ import asyncio -import json import uuid from http import HTTPStatus -from math import ceil -from typing import List, Optional, Union -from urllib.parse import urlparse +from typing import List, Optional -import httpx from fastapi import ( APIRouter, Body, @@ -17,6 +13,7 @@ from fastapi import ( Request, ) from fastapi.responses import JSONResponse +from lnurl import decode as lnurl_decode from loguru import logger from sse_starlette.sse import EventSourceResponse @@ -24,7 +21,6 @@ from lnbits import bolt11 from lnbits.core.db import db from lnbits.core.models import ( CreateInvoice, - CreateLnurl, DecodePayment, KeyType, Payment, @@ -37,13 +33,10 @@ from lnbits.decorators import ( WalletTypeInfo, get_key_type, parse_filters, - require_admin_key, require_invoice_key, ) from lnbits.helpers import generate_filter_params_openapi -from lnbits.lnurl import decode as lnurl_decode from lnbits.settings import settings -from lnbits.utils.exchange_rates import fiat_amount_as_satoshis from ..crud import ( DateTrunc, @@ -168,36 +161,9 @@ async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet): assert payment_db is not None, "payment not found" checking_id = payment_db.checking_id - invoice = bolt11.decode(payment_request) - - lnurl_response: Union[None, bool, str] = None - if data.lnurl_callback: - headers = {"User-Agent": settings.user_agent} - async with httpx.AsyncClient(headers=headers) as client: - try: - r = await client.get( - data.lnurl_callback, - params={ - "pr": payment_request, - }, - timeout=10, - ) - if r.is_error: - lnurl_response = r.text - else: - resp = json.loads(r.text) - if resp["status"] != "OK": - lnurl_response = resp["reason"] - else: - lnurl_response = True - except (httpx.ConnectError, httpx.RequestError) as ex: - logger.error(ex) - lnurl_response = False - return { - "payment_hash": invoice.payment_hash, + "payment_hash": payment_hash, "payment_request": payment_request, - "lnurl_response": lnurl_response, # maintain backwards compatibility with API clients: "checking_id": checking_id, } @@ -268,89 +234,10 @@ async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONRespo ) -@payment_router.post("/lnurl") -async def api_payments_pay_lnurl( - data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key) -): - domain = urlparse(data.callback).netloc - - headers = {"User-Agent": settings.user_agent} - async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client: - try: - if data.unit and data.unit != "sat": - amount_msat = await fiat_amount_as_satoshis(data.amount, data.unit) - # no msat precision - amount_msat = ceil(amount_msat // 1000) * 1000 - else: - amount_msat = data.amount - r = await client.get( - data.callback, - params={"amount": amount_msat, "comment": data.comment}, - timeout=40, - ) - if r.is_error: - raise httpx.ConnectError("LNURL callback connection error") - r.raise_for_status() - except (httpx.ConnectError, httpx.RequestError) as exc: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Failed to connect to {domain}.", - ) from exc - - params = json.loads(r.text) - if params.get("status") == "ERROR": - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"{domain} said: '{params.get('reason', '')}'", - ) - - if not params.get("pr"): - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"{domain} did not return a payment request.", - ) - - invoice = bolt11.decode(params["pr"]) - if invoice.amount_msat != amount_msat: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=( - ( - f"{domain} returned an invalid invoice. Expected" - f" {amount_msat} msat, got {invoice.amount_msat}." - ), - ), - ) - - extra = {} - - if params.get("successAction"): - extra["success_action"] = params["successAction"] - if data.comment: - extra["comment"] = data.comment - if data.unit and data.unit != "sat": - extra["fiat_currency"] = data.unit - extra["fiat_amount"] = data.amount / 1000 - assert data.description is not None, "description is required" - payment_hash = await pay_invoice( - wallet_id=wallet.wallet.id, - payment_request=params["pr"], - description=data.description, - extra=extra, - ) - - return { - "success_action": params.get("successAction"), - "payment_hash": payment_hash, - # maintain backwards compatibility with API clients: - "checking_id": payment_hash, - } - - async def subscribe_wallet_invoices(request: Request, wallet: Wallet): """ Subscribe to new invoices for a wallet. Can be wrapped in EventSourceResponse. - Listenes invoming payments for a wallet and yields jsons with payment details. + Listenes incoming payments for a wallet and yields jsons with payment details. """ this_wallet_id = wallet.id diff --git a/lnbits/static/js/base.js b/lnbits/static/js/base.js index 363633594..a41a09224 100644 --- a/lnbits/static/js/base.js +++ b/lnbits/static/js/base.js @@ -22,18 +22,11 @@ window.LNbits = { data: data }) }, - createInvoice: async function ( - wallet, - amount, - memo, - unit = 'sat', - lnurlCallback = null - ) { + createInvoice: async function (wallet, amount, memo, unit = 'sat') { return this.request('post', '/api/v1/payments', wallet.inkey, { out: false, amount: amount, memo: memo, - lnurl_callback: lnurlCallback, unit: unit }) }, @@ -43,27 +36,24 @@ window.LNbits = { bolt11: bolt11 }) }, - payLnurl: function ( - wallet, - callback, - description_hash, - amount, - description = '', - comment = '', - unit = '' - ) { - return this.request('post', '/api/v1/payments/lnurl', wallet.adminkey, { - callback, - description_hash, + payLnurl: function (wallet, pay_response, amount, comment = '', unit = '') { + return this.request('post', '/lnurl/api/v1/pay', wallet.adminkey, { + pay_response, amount, comment, - description, unit }) }, - authLnurl: function (wallet, callback) { - return this.request('post', '/api/v1/lnurlauth', wallet.adminkey, { - callback + withdrawLnurl: function (wallet, withdraw_response, amount, memo) { + return this.request('post', '/lnurl/api/v1/withdraw', wallet.adminkey, { + withdraw_response, + amount, + memo + }) + }, + authLnurl: function (wallet, auth_response) { + return this.request('post', '/lnurl/api/v1/auth', wallet.adminkey, { + auth_response }) }, createAccount: function (name) { diff --git a/lnbits/static/js/wallet.js b/lnbits/static/js/wallet.js index 38ad63aa7..7aa53481f 100644 --- a/lnbits/static/js/wallet.js +++ b/lnbits/static/js/wallet.js @@ -30,6 +30,7 @@ new Vue({ invoice: null, lnurlpay: null, lnurlauth: null, + lnurlwithdraw: null, data: { request: '', amount: 0, @@ -130,6 +131,12 @@ new Vue({ this.receive.paymentHash = null } }, + getLnurlDescription: function (lnurl) { + const description = JSON.parse(lnurl.metadata).filter( + item => item[0] === 'text/plain' + ) + return description[0][1] + }, createInvoice: function () { this.receive.status = 'loading' if (LNBITS_DENOMINATION != 'sats') { @@ -147,30 +154,6 @@ new Vue({ this.receive.status = 'success' this.receive.paymentReq = response.data.payment_request this.receive.paymentHash = response.data.payment_hash - - if (response.data.lnurl_response !== null) { - if (response.data.lnurl_response === false) { - response.data.lnurl_response = `Unable to connect` - } - - if (typeof response.data.lnurl_response === 'string') { - // failure - this.$q.notify({ - timeout: 5000, - type: 'warning', - message: `${this.receive.lnurl.domain} lnurl-withdraw call failed.`, - caption: response.data.lnurl_response - }) - return - } else if (response.data.lnurl_response === true) { - // success - this.$q.notify({ - timeout: 5000, - message: `Invoice sent to ${this.receive.lnurl.domain}!`, - spinner: true - }) - } - } }) .then(() => { this.updatePayments = !this.updatePayments @@ -232,11 +215,7 @@ new Vue({ this.parse.data.request.match(/[\w.+-~_]+@[\w.+-~_]/) ) { LNbits.api - .request( - 'GET', - '/api/v1/lnurlscan/' + this.parse.data.request, - this.g.wallet.adminkey - ) + .request('GET', '/lnurl/api/v1/scan/' + this.parse.data.request, null) .catch(err => { LNbits.utils.notifyApiError(err) }) @@ -253,28 +232,21 @@ new Vue({ return } - if (data.kind === 'pay') { + const url = new URL(data.callback) + this.parse.domain = url.host + if (data.tag === 'payRequest') { this.parse.lnurlpay = Object.freeze(data) this.parse.data.amount = data.minSendable / 1000 - } else if (data.kind === 'auth') { + } else if (data.tag === 'login') { this.parse.lnurlauth = Object.freeze(data) - } else if (data.kind === 'withdraw') { - this.parse.show = false - this.receive.show = true - this.receive.status = 'pending' - this.receive.paymentReq = null - this.receive.paymentHash = null - this.receive.data.amount = data.maxWithdrawable / 1000 - this.receive.data.memo = data.defaultDescription - this.receive.minMax = [ + } else if (data.tag === 'withdrawRequest') { + this.parse.lnurlwithdraw = Object.freeze(data) + this.parse.data.amount = data.maxWithdrawable / 1000 + this.parse.data.memo = data.defaultDescription + this.parse.minMax = [ data.minWithdrawable / 1000, data.maxWithdrawable / 1000 ] - this.receive.lnurl = { - domain: data.domain, - callback: data.callback, - fixed: data.fixed - } } }) return @@ -364,6 +336,42 @@ new Vue({ this.parse.show = false }) }, + withdrawLnurl: function () { + let dismissPaymentMsg = this.$q.notify({ + timeout: 0, + message: 'Processing withdraw...' + }) + LNbits.api + .withdrawLnurl( + this.g.wallet, + this.parse.lnurlwithdraw, + this.parse.data.amount * 1000, + this.parse.data.memo + ) + .then(response => { + this.parse.show = false + this.parse.lnurlwithdraw = null + clearInterval(this.parse.paymentChecker) + setTimeout(() => { + clearInterval(this.parse.paymentChecker) + }, 40000) + this.parse.paymentChecker = setInterval(() => { + LNbits.api + .getPayment(this.g.wallet, response.data.payment_hash) + .then(res => { + if (res.data.paid) { + dismissPaymentMsg() + clearInterval(this.parse.paymentChecker) + this.updatePayments = !this.updatePayments + } + }) + }, 2000) + }) + .catch(err => { + dismissPaymentMsg() + LNbits.utils.notifyApiError(err) + }) + }, payLnurl: function () { let dismissPaymentMsg = this.$q.notify({ timeout: 0, @@ -373,10 +381,8 @@ new Vue({ LNbits.api .payLnurl( this.g.wallet, - this.parse.lnurlpay.callback, - this.parse.lnurlpay.description_hash, + this.parse.lnurlpay, this.parse.data.amount * 1000, - this.parse.lnurlpay.description.slice(0, 120), this.parse.data.comment, this.parse.data.unit ) @@ -453,7 +459,7 @@ new Vue({ }) LNbits.api - .authLnurl(this.g.wallet, this.parse.lnurlauth.callback) + .authLnurl(this.g.wallet, this.parse.lnurlauth) .then(_ => { dismissAuthMsg() this.$q.notify({ diff --git a/poetry.lock b/poetry.lock index 8f78ae336..f42276c19 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "anyio" @@ -767,13 +767,13 @@ wmi = ["wmi (>=1.5.1)"] [[package]] name = "ecdsa" -version = "0.18.0" +version = "0.19.0" description = "ECDSA cryptographic signature library (pure python)" optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.6" files = [ - {file = "ecdsa-0.18.0-py2.py3-none-any.whl", hash = "sha256:80600258e7ed2f16b9aa1d7c295bd70194109ad5a30fdee0eaeefef1d4c559dd"}, - {file = "ecdsa-0.18.0.tar.gz", hash = "sha256:190348041559e21b22a1d65cee485282ca11a6f81d503fddb84d5017e9ed1e49"}, + {file = "ecdsa-0.19.0-py2.py3-none-any.whl", hash = "sha256:2cea9b88407fdac7bbeca0833b189e4c9c53f2ef1e1eaa29f6224dbc809b707a"}, + {file = "ecdsa-0.19.0.tar.gz", hash = "sha256:60eaad1199659900dd0af521ed462b793bbdf867432b3948e87416ae4caf6bf8"}, ] [package.dependencies] @@ -987,39 +987,40 @@ cryptography = ">=2.5" [[package]] name = "httpcore" -version = "0.18.0" +version = "1.0.5" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" files = [ - {file = "httpcore-0.18.0-py3-none-any.whl", hash = "sha256:adc5398ee0a476567bf87467063ee63584a8bce86078bf748e48754f60202ced"}, - {file = "httpcore-0.18.0.tar.gz", hash = "sha256:13b5e5cd1dca1a6636a6aaea212b19f4f85cd88c366a2b82304181b769aab3c9"}, + {file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"}, + {file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"}, ] [package.dependencies] -anyio = ">=3.0,<5.0" certifi = "*" h11 = ">=0.13,<0.15" -sniffio = "==1.*" [package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<0.26.0)"] [[package]] name = "httpx" -version = "0.25.0" +version = "0.27.0" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" files = [ - {file = "httpx-0.25.0-py3-none-any.whl", hash = "sha256:181ea7f8ba3a82578be86ef4171554dd45fec26a02556a744db029a0a27b7100"}, - {file = "httpx-0.25.0.tar.gz", hash = "sha256:47ecda285389cb32bb2691cc6e069e3ab0205956f681c5b2ad2325719751d875"}, + {file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"}, + {file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"}, ] [package.dependencies] +anyio = "*" certifi = "*" -httpcore = ">=0.18.0,<0.19.0" +httpcore = "==1.*" idna = "*" sniffio = "*" @@ -1253,19 +1254,21 @@ rediscluster = ["redis (>=4.2.0,!=4.5.2,!=4.5.3)"] [[package]] name = "lnurl" -version = "0.4.2" +version = "0.5.1" description = "LNURL implementation for Python." optional = false -python-versions = ">=3.9,<4.0" +python-versions = "<4.0,>=3.9" files = [ - {file = "lnurl-0.4.2-py3-none-any.whl", hash = "sha256:93f79ae7e0b0c66fed5b29ac1520e85e3e2c8648561a4b42974f0b7bffd34d84"}, - {file = "lnurl-0.4.2.tar.gz", hash = "sha256:c5e708b255d5333a0c08ceffe90ae4be6d2d09eb51dc8c35d19d8aa4cb21842a"}, + {file = "lnurl-0.5.1-py3-none-any.whl", hash = "sha256:41a03eac08c32b9ee2c6d83b9f1e88bcc5b393b36d82a41d73a02180fa04f249"}, + {file = "lnurl-0.5.1.tar.gz", hash = "sha256:a099899e622b23e6197c3f2e3ba38499658f1e6e9e2c456d55caa57373ca925b"}, ] [package.dependencies] bech32 = ">=1.2.0,<2.0.0" +bolt11 = ">=2.0.5,<3.0.0" +ecdsa = ">=0.19.0,<0.20.0" +httpx = ">=0.27.0,<0.28.0" pydantic = ">=1,<2" -requests = ">=2.31.0,<3.0.0" [[package]] name = "loguru" @@ -3054,4 +3057,4 @@ liquid = ["wallycore"] [metadata] lock-version = "2.0" python-versions = "^3.10 | ^3.9" -content-hash = "33f9d6ee851ae77b6e02cc8964d1a6ea233ba3ff4cfaeeb082c327654c9cd7e0" +content-hash = "4e316d3fcc22d41ada4de66e57049011312664f6b71853b0be441e289a402595" diff --git a/pyproject.toml b/pyproject.toml index a9e016360..d0e0392d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,11 +15,10 @@ packages = [ python = "^3.10 | ^3.9" bech32 = "1.2.0" click = "8.1.7" -ecdsa = "0.18.0" fastapi = "0.109.2" -httpx = "0.25.0" +httpx = "0.27.0" jinja2 = "3.1.4" -lnurl = "0.4.2" +lnurl = "0.5.1" psycopg2-binary = "2.9.7" pydantic = "1.10.17" pyqrcode = "1.2.1" @@ -132,7 +131,6 @@ module = [ "lnurl.*", "bolt11.*", "bitstring.*", - "ecdsa.*", "psycopg2.*", "pyngrok.*", "pyln.client.*",