diff --git a/lnbits/extensions/lnurlpos/lnurl.py b/lnbits/extensions/lnurlpos/lnurl.py index 69164824..0369b0aa 100644 --- a/lnbits/extensions/lnurlpos/lnurl.py +++ b/lnbits/extensions/lnurlpos/lnurl.py @@ -1,9 +1,10 @@ +import base64 import hashlib from http import HTTPStatus +from typing import Optional from fastapi import Request from fastapi.param_functions import Query -from lnurl import LnurlPayActionResponse, LnurlPayResponse # type: ignore from starlette.exceptions import HTTPException from lnbits.core.services import create_invoice @@ -28,53 +29,101 @@ async def lnurl_response( nonce: str = Query(None), pos_id: str = Query(None), payload: str = Query(None), +): + return await handle_lnurl_firstrequest( + request, pos_id, nonce, payload, verify_checksum=False + ) + + +@lnurlpos_ext.get( + "/api/v2/lnurl/{pos_id}", + status_code=HTTPStatus.OK, + name="lnurlpos.lnurl_v2_params", +) +async def lnurl_v2_params( + request: Request, + pos_id: str = Query(None), + n: str = Query(None), + p: str = Query(None), +): + return await handle_lnurl_firstrequest(request, pos_id, n, p, verify_checksum=True) + + +async def handle_lnurl_firstrequest( + request: Request, pos_id: str, nonce: str, payload: str, verify_checksum: bool ): pos = await get_lnurlpos(pos_id) if not pos: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="lnurlpos not found." - ) - nonce1 = bytes.fromhex(nonce) - payload1 = bytes.fromhex(payload) - h = hashlib.sha256(nonce1) + return { + "status": "ERROR", + "reason": f"lnurlpos {pos_id} not found on this server.", + } + + try: + nonceb = bytes.fromhex(nonce) + except ValueError: + try: + nonce += "=" * ((4 - len(nonce) % 4) % 4) + nonceb = base64.urlsafe_b64decode(nonce) + except: + return { + "status": "ERROR", + "reason": f"Invalid hex or base64 nonce: {nonce}", + } + + try: + payloadb = bytes.fromhex(payload) + except ValueError: + try: + payload += "=" * ((4 - len(payload) % 4) % 4) + payloadb = base64.urlsafe_b64decode(payload) + except: + return { + "status": "ERROR", + "reason": f"Invalid hex or base64 payload: {payload}", + } + + h = hashlib.sha256(nonceb) h.update(pos.key.encode()) s = h.digest() - res = bytearray(payload1) + + res = bytearray(payloadb) for i in range(len(res)): res[i] = res[i] ^ s[i] - decryptedAmount = float(int.from_bytes(res[2:6], "little") / 100) - decryptedPin = int.from_bytes(res[:2], "little") - if type(decryptedAmount) != float: - raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not an amount.") + + if verify_checksum: + checksum = res[6:8] + if hashlib.sha256(res[0:6]).digest()[0:2] != checksum: + return {"status": "ERROR", "reason": "Invalid checksum!"} + + pin = int.from_bytes(res[0:2], "little") + amount = int.from_bytes(res[2:6], "little") + price_msat = ( - await fiat_amount_as_satoshis(decryptedAmount, pos.currency) + await fiat_amount_as_satoshis(float(amount) / 100, pos.currency) if pos.currency != "sat" - else pos.currency + else amount ) * 1000 lnurlpospayment = await create_lnurlpospayment( posid=pos.id, payload=payload, sats=price_msat, - pin=decryptedPin, + pin=pin, payhash="payment_hash", ) - if not lnurlpospayment: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, detail="Could not create payment" - ) + return {"status": "ERROR", "reason": "Could not create payment."} - resp = LnurlPayResponse( - callback=request.url_for( + return { + "tag": "payRequest", + "callback": request.url_for( "lnurlpos.lnurl_callback", paymentid=lnurlpospayment.id ), - min_sendable=price_msat, - max_sendable=price_msat, - metadata=await pos.lnurlpay_metadata(), - ) - - return resp.dict() + "minSendable": price_msat, + "maxSendable": price_msat, + "metadata": await pos.lnurlpay_metadata(), + } @lnurlpos_ext.get( @@ -102,10 +151,14 @@ async def lnurl_callback(request: Request, paymentid: str = Query(None)): lnurlpospayment_id=paymentid, payhash=payment_hash ) - resp = LnurlPayActionResponse( - pr=payment_request, - success_action=pos.success_action(paymentid, request), - routes=[], - ) + return { + "pr": payment_request, + "successAction": { + "tag": "url", + "description": "Check the attached link", + "url": req.url_for("lnurlpos.displaypin", paymentid=paymentid), + }, + "routes": [], + } return resp.dict() diff --git a/lnbits/extensions/lnurlpos/models.py b/lnbits/extensions/lnurlpos/models.py index 4cb9fa8c..a8a299e2 100644 --- a/lnbits/extensions/lnurlpos/models.py +++ b/lnbits/extensions/lnurlpos/models.py @@ -35,16 +35,6 @@ class lnurlposs(BaseModel): async def lnurlpay_metadata(self) -> LnurlPayMetadata: return LnurlPayMetadata(json.dumps([["text/plain", self.title]])) - def success_action( - self, paymentid: str, req: Request - ) -> Optional[LnurlPaySuccessAction]: - - return UrlAction( - url=req.url_for("lnurlpos.displaypin", paymentid=paymentid), - description="Check the attached link", - ) - - class lnurlpospayment(BaseModel): id: str posid: str diff --git a/lnbits/extensions/lnurlpos/templates/lnurlpos/_api_docs.html b/lnbits/extensions/lnurlpos/templates/lnurlpos/_api_docs.html index c4960d64..470d2248 100644 --- a/lnbits/extensions/lnurlpos/templates/lnurlpos/_api_docs.html +++ b/lnbits/extensions/lnurlpos/templates/lnurlpos/_api_docs.html @@ -1,7 +1,7 @@

- Register LNURLPoS devices to recieve payments in your LNbits wallet.
+ Register LNURLPoS devices to receive payments in your LNbits wallet.
Build your own here https://github.com/arcbtc/LNURLPoS

Copy to LNURLPoS device
- {% raw %} String server = "{{location}}";
- String posId = "{{settingsDialog.data.id}}";
+ {% raw %} String server = "{{location}}/lnurlpos/api/v2/lnurl/{{settingsDialog.data.id}}";
String key = "{{settingsDialog.data.key}}";
String currency = "{{settingsDialog.data.currency}}";{% endraw %}
diff --git a/lnbits/utils/exchange_rates.py b/lnbits/utils/exchange_rates.py index 8d150996..39e6a73a 100644 --- a/lnbits/utils/exchange_rates.py +++ b/lnbits/utils/exchange_rates.py @@ -253,6 +253,7 @@ async def btc_price(currency: str) -> float: await send_channel.put(rate) except ( TypeError, # CoinMate returns HTTPStatus 200 but no data when a currency pair is not found + KeyError, # Kraken's response dictionary doesn't include keys we look up for httpx.ConnectTimeout, httpx.ConnectError, httpx.ReadTimeout,