diff --git a/lnbits/extensions/lnurldevice/README.md b/lnbits/extensions/lnurldevice/README.md new file mode 100644 index 000000000..01ce63825 --- /dev/null +++ b/lnbits/extensions/lnurldevice/README.md @@ -0,0 +1,3 @@ +# LNURLDevice + +For offline LNURL devices diff --git a/lnbits/extensions/lnurldevice/__init__.py b/lnbits/extensions/lnurldevice/__init__.py new file mode 100644 index 000000000..54849c957 --- /dev/null +++ b/lnbits/extensions/lnurldevice/__init__.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter + +from lnbits.db import Database +from lnbits.helpers import template_renderer + +db = Database("ext_lnurldevice") + +lnurldevice_ext: APIRouter = APIRouter(prefix="/lnurldevice", tags=["lnurldevice"]) + + +def lnurldevice_renderer(): + return template_renderer(["lnbits/extensions/lnurldevice/templates"]) + + +from .lnurl import * # noqa +from .views import * # noqa +from .views_api import * # noqa diff --git a/lnbits/extensions/lnurldevice/config.json b/lnbits/extensions/lnurldevice/config.json new file mode 100644 index 000000000..66b4891ac --- /dev/null +++ b/lnbits/extensions/lnurldevice/config.json @@ -0,0 +1,6 @@ +{ + "name": "LNURLDevice", + "short_description": "For offline LNURL devices", + "icon": "point_of_sale", + "contributors": ["arcbtc"] +} diff --git a/lnbits/extensions/lnurldevice/crud.py b/lnbits/extensions/lnurldevice/crud.py new file mode 100644 index 000000000..2869b91ac --- /dev/null +++ b/lnbits/extensions/lnurldevice/crud.py @@ -0,0 +1,120 @@ +from typing import List, Optional, Union + +from lnbits.helpers import urlsafe_short_hash + +from . import db +from .models import createLnurldevice, lnurldevicepayment, lnurldevices + +###############lnurldeviceS########################## + + +async def create_lnurldevice(data: createLnurldevice,) -> lnurldevices: + lnurldevice_id = urlsafe_short_hash() + lnurldevice_key = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO lnurldevice.lnurldevices ( + id, + key, + title, + wallet, + currency, + device, + profit + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (lnurldevice_id, lnurldevice_key, data.title, data.wallet, data.currency, data.device, data.profit,), + ) + return await get_lnurldevice(lnurldevice_id) + + +async def update_lnurldevice(lnurldevice_id: str, **kwargs) -> Optional[lnurldevices]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE lnurldevice.lnurldevices SET {q} WHERE id = ?", + (*kwargs.values(), lnurldevice_id), + ) + row = await db.fetchone( + "SELECT * FROM lnurldevice.lnurldevices WHERE id = ?", (lnurldevice_id,) + ) + return lnurldevices(**row) if row else None + + +async def get_lnurldevice(lnurldevice_id: str) -> lnurldevices: + row = await db.fetchone( + "SELECT * FROM lnurldevice.lnurldevices WHERE id = ?", (lnurldevice_id,) + ) + return lnurldevices(**row) if row else None + + +async def get_lnurldevices(wallet_ids: Union[str, List[str]]) -> List[lnurldevices]: + wallet_ids = [wallet_ids] + q = ",".join(["?"] * len(wallet_ids[0])) + rows = await db.fetchall( + f""" + SELECT * FROM lnurldevice.lnurldevices WHERE wallet IN ({q}) + ORDER BY id + """, + (*wallet_ids,), + ) + + return [lnurldevices(**row) if row else None for row in rows] + + +async def delete_lnurldevice(lnurldevice_id: str) -> None: + await db.execute("DELETE FROM lnurldevice.lnurldevices WHERE id = ?", (lnurldevice_id,)) + + ########################lnuldevice payments########################### + + +async def create_lnurldevicepayment( + deviceid: str, + payload: Optional[str] = None, + pin: Optional[str] = None, + payhash: Optional[str] = None, + sats: Optional[int] = 0, +) -> lnurldevicepayment: + lnurldevicepayment_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO lnurldevice.lnurldevicepayment ( + id, + deviceid, + payload, + pin, + payhash, + sats + ) + VALUES (?, ?, ?, ?, ?, ?) + """, + (lnurldevicepayment_id, deviceid, payload, pin, payhash, sats), + ) + return await get_lnurldevicepayment(lnurldevicepayment_id) + + +async def update_lnurldevicepayment( + lnurldevicepayment_id: str, **kwargs +) -> Optional[lnurldevicepayment]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE lnurldevice.lnurldevicepayment SET {q} WHERE id = ?", + (*kwargs.values(), lnurldevicepayment_id), + ) + row = await db.fetchone( + "SELECT * FROM lnurldevice.lnurldevicepayment WHERE id = ?", (lnurldevicepayment_id,) + ) + return lnurldevicepayment(**row) if row else None + + +async def get_lnurldevicepayment(lnurldevicepayment_id: str) -> lnurldevicepayment: + row = await db.fetchone( + "SELECT * FROM lnurldevice.lnurldevicepayment WHERE id = ?", (lnurldevicepayment_id,) + ) + return lnurldevicepayment(**row) if row else None + +async def get_lnurlpayload(lnurldevicepayment_payload: str) -> lnurldevicepayment: + row = await db.fetchone( + "SELECT * FROM lnurldevice.lnurldevicepayment WHERE payload = ?", (lnurldevicepayment_payload,) + ) + return lnurldevicepayment(**row) if row else None \ No newline at end of file diff --git a/lnbits/extensions/lnurldevice/lnurl.py b/lnbits/extensions/lnurldevice/lnurl.py new file mode 100644 index 000000000..b3e4869d4 --- /dev/null +++ b/lnbits/extensions/lnurldevice/lnurl.py @@ -0,0 +1,233 @@ +import base64 +import hashlib +from http import HTTPStatus +from typing import Optional + +from embit import bech32 +from embit import compact +import base64 +from io import BytesIO +import hmac + +from fastapi import Request +from fastapi.param_functions import Query +from starlette.exceptions import HTTPException + +from lnbits.core.services import create_invoice +from lnbits.utils.exchange_rates import fiat_amount_as_satoshis +from lnbits.core.views.api import pay_invoice + + +from . import lnurldevice_ext +from .crud import ( + create_lnurldevicepayment, + get_lnurldevice, + get_lnurldevicepayment, + update_lnurldevicepayment, + get_lnurlpayload, +) + + +def bech32_decode(bech): + """tweaked version of bech32_decode that ignores length limitations""" + if (any(ord(x) < 33 or ord(x) > 126 for x in bech)) or ( + bech.lower() != bech and bech.upper() != bech + ): + return + bech = bech.lower() + device = bech.rfind("1") + if device < 1 or device + 7 > len(bech): + return + if not all(x in bech32.CHARSET for x in bech[device + 1 :]): + return + hrp = bech[:device] + data = [bech32.CHARSET.find(x) for x in bech[device + 1 :]] + encoding = bech32.bech32_verify_checksum(hrp, data) + if encoding is None: + return + return bytes(bech32.convertbits(data[:-6], 5, 8, False)) + + +def xor_decrypt(key, blob): + s = BytesIO(blob) + variant = s.read(1)[0] + if variant != 1: + raise RuntimeError("Not implemented") + # reading nonce + l = s.read(1)[0] + nonce = s.read(l) + if len(nonce) != l: + raise RuntimeError("Missing nonce bytes") + if l < 8: + raise RuntimeError("Nonce is too short") + # reading payload + l = s.read(1)[0] + payload = s.read(l) + if len(payload) > 32: + raise RuntimeError("Payload is too long for this encryption method") + if len(payload) != l: + raise RuntimeError("Missing payload bytes") + hmacval = s.read() + expected = hmac.new( + key, b"Data:" + blob[: -len(hmacval)], digestmod="sha256" + ).digest() + if len(hmacval) < 8: + raise RuntimeError("HMAC is too short") + if hmacval != expected[: len(hmacval)]: + raise RuntimeError("HMAC is invalid") + secret = hmac.new(key, b"Round secret:" + nonce, digestmod="sha256").digest() + payload = bytearray(payload) + for i in range(len(payload)): + payload[i] = payload[i] ^ secret[i] + s = BytesIO(payload) + pin = compact.read_from(s) + amount_in_cent = compact.read_from(s) + return pin, amount_in_cent + + +@lnurldevice_ext.get( + "/api/v1/lnurl/{device_id}", + status_code=HTTPStatus.OK, + name="lnurldevice.lnurl_v1_params", +) +async def lnurl_v1_params( + request: Request, + device_id: str = Query(None), + p: str = Query(None), + atm: str = Query(None), +): + device = await get_lnurldevice(device_id) + if not device: + return { + "status": "ERROR", + "reason": f"lnurldevice {device_id} not found on this server", + } + paymentcheck = await get_lnurlpayload(p) + if device.device == "atm": + if paymentcheck: + return { + "status": "ERROR", + "reason": f"Payment already claimed", + } + + if len(p) % 4 > 0: + p += "=" * (4 - (len(p) % 4)) + + data = base64.urlsafe_b64decode(p) + pin = 0 + amount_in_cent = 0 + try: + result = xor_decrypt(device.key.encode(), data) + pin = result[0] + amount_in_cent = result[1] + except Exception as exc: + return {"status": "ERROR", "reason": str(exc)} + + price_msat = ( + await fiat_amount_as_satoshis(float(amount_in_cent) / 100, device.currency) + if device.currency != "sat" + else amount_in_cent + ) * 1000 + + if atm: + if device.device != "atm": + return {"status": "ERROR", "reason": "Not ATM device."} + price_msat = int(price_msat * (1 - (device.profit / 100)) / 1000) + lnurldevicepayment = await create_lnurldevicepayment( + deviceid=device.id, + payload=p, + sats=price_msat * 1000, + pin=pin, + payhash="payment_hash", + ) + if not lnurldevicepayment: + return {"status": "ERROR", "reason": "Could not create payment."} + return { + "tag": "withdrawRequest", + "callback": request.url_for( + "lnurldevice.lnurl_callback", paymentid=lnurldevicepayment.id + ), + "k1": lnurldevicepayment.id, + "minWithdrawable": price_msat * 1000, + "maxWithdrawable": price_msat * 1000, + "defaultDescription": device.title, + } + price_msat = int(price_msat * ((device.profit / 100) + 1) / 1000) + print(price_msat) + lnurldevicepayment = await create_lnurldevicepayment( + deviceid=device.id, + payload=p, + sats=price_msat * 1000, + pin=pin, + payhash="payment_hash", + ) + if not lnurldevicepayment: + return {"status": "ERROR", "reason": "Could not create payment."} + return { + "tag": "payRequest", + "callback": request.url_for( + "lnurldevice.lnurl_callback", paymentid=lnurldevicepayment.id + ), + "minSendable": price_msat * 1000, + "maxSendable": price_msat * 1000, + "metadata": await device.lnurlpay_metadata(), + } + + + +@lnurldevice_ext.get( + "/api/v1/lnurl/cb/{paymentid}", + status_code=HTTPStatus.OK, + name="lnurldevice.lnurl_callback", +) +async def lnurl_callback(request: Request, paymentid: str = Query(None), pr: str = Query(None), k1: str = Query(None)): + lnurldevicepayment = await get_lnurldevicepayment(paymentid) + device = await get_lnurldevice(lnurldevicepayment.deviceid) + if not device: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="lnurldevice not found." + ) + if pr: + if lnurldevicepayment.id != k1: + return {"status": "ERROR", "reason": "Bad K1"} + if lnurldevicepayment.payhash != "payment_hash": + return { + "status": "ERROR", + "reason": f"Payment already claimed", + } + lnurldevicepayment = await update_lnurldevicepayment( + lnurldevicepayment_id=paymentid, payhash=lnurldevicepayment.payload + ) + + await pay_invoice( + wallet_id=device.wallet, + payment_request=pr, + max_sat=lnurldevicepayment.sats / 1000, + extra={"tag": "withdraw"}, + ) + return {"status": "OK"} + print(lnurldevicepayment.sats) + payment_hash, payment_request = await create_invoice( + wallet_id=device.wallet, + amount=lnurldevicepayment.sats / 1000, + memo=device.title, + description_hash=hashlib.sha256( + (await device.lnurlpay_metadata()).encode("utf-8") + ).digest(), + extra={"tag": "PoS"}, + ) + lnurldevicepayment = await update_lnurldevicepayment( + lnurldevicepayment_id=paymentid, payhash=payment_hash + ) + + return { + "pr": payment_request, + "successAction": { + "tag": "url", + "description": "Check the attached link", + "url": request.url_for("lnurldevice.displaypin", paymentid=paymentid), + }, + "routes": [], + } + + return resp.dict() diff --git a/lnbits/extensions/lnurldevice/migrations.py b/lnbits/extensions/lnurldevice/migrations.py new file mode 100644 index 000000000..4ade19a92 --- /dev/null +++ b/lnbits/extensions/lnurldevice/migrations.py @@ -0,0 +1,76 @@ +from lnbits.db import Database + +db2 = Database("ext_lnurlpos") + +async def m001_initial(db): + """ + Initial lnurldevice table. + """ + await db.execute( + f""" + CREATE TABLE lnurldevice.lnurldevices ( + id TEXT NOT NULL PRIMARY KEY, + key TEXT NOT NULL, + title TEXT NOT NULL, + wallet TEXT NOT NULL, + currency TEXT NOT NULL, + device TEXT NOT NULL, + profit FLOAT NOT NULL, + timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """ + ) + await db.execute( + f""" + CREATE TABLE lnurldevice.lnurldevicepayment ( + id TEXT NOT NULL PRIMARY KEY, + deviceid TEXT NOT NULL, + payhash TEXT, + payload TEXT NOT NULL, + pin INT, + sats INT, + timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """ + ) + + +async def m002_redux(db): + """ + Moves everything from lnurlpos to lnurldevices + """ + for row in [ + list(row) for row in await db2.fetchall("SELECT * FROM lnurlpos.lnurlposs") + ]: + await db.execute( + """ + INSERT INTO lnurldevice.lnurldevices ( + id, + key, + title, + wallet, + currency, + device, + profit + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (row[0], row[1], row[2], row[3], row[4], "pos", 0), + ) + for row in [ + list(row) for row in await db2.fetchall("SELECT * FROM lnurlpos.lnurlpospayment") + ]: + await db.execute( + """ + INSERT INTO lnurldevice.lnurldevicepayment ( + id, + deviceid, + payhash, + payload, + pin, + sats + ) + VALUES (?, ?, ?, ?, ?, ?) + """, + (row[0], row[1], row[3], row[4], row[5], row[6]), + ) \ No newline at end of file diff --git a/lnbits/extensions/lnurlpos/models.py b/lnbits/extensions/lnurldevice/models.py similarity index 69% rename from lnbits/extensions/lnurlpos/models.py rename to lnbits/extensions/lnurldevice/models.py index a8a299e27..b436f9d42 100644 --- a/lnbits/extensions/lnurlpos/models.py +++ b/lnbits/extensions/lnurldevice/models.py @@ -11,33 +11,37 @@ from pydantic import BaseModel from pydantic.main import BaseModel -class createLnurlpos(BaseModel): +class createLnurldevice(BaseModel): title: str wallet: str currency: str + device: str + profit: float -class lnurlposs(BaseModel): +class lnurldevices(BaseModel): id: str key: str title: str wallet: str currency: str + device: str + profit: float timestamp: str - def from_row(cls, row: Row) -> "lnurlposs": + def from_row(cls, row: Row) -> "lnurldevices": return cls(**dict(row)) def lnurl(self, req: Request) -> Lnurl: - url = req.url_for("lnurlpos.lnurl_response", pos_id=self.id, _external=True) + url = req.url_for("lnurldevice.lnurl_response", device_id=self.id, _external=True) return lnurl_encode(url) async def lnurlpay_metadata(self) -> LnurlPayMetadata: return LnurlPayMetadata(json.dumps([["text/plain", self.title]])) -class lnurlpospayment(BaseModel): +class lnurldevicepayment(BaseModel): id: str - posid: str + deviceid: str payhash: str payload: str pin: int @@ -45,5 +49,5 @@ class lnurlpospayment(BaseModel): timestamp: str @classmethod - def from_row(cls, row: Row) -> "lnurlpospayment": + def from_row(cls, row: Row) -> "lnurldevicepayment": return cls(**dict(row)) diff --git a/lnbits/extensions/lnurlpos/templates/lnurlpos/_api_docs.html b/lnbits/extensions/lnurldevice/templates/lnurldevice/_api_docs.html similarity index 71% rename from lnbits/extensions/lnurlpos/templates/lnurlpos/_api_docs.html rename to lnbits/extensions/lnurldevice/templates/lnurldevice/_api_docs.html index 470d22484..af69b76e3 100644 --- a/lnbits/extensions/lnurlpos/templates/lnurlpos/_api_docs.html +++ b/lnbits/extensions/lnurldevice/templates/lnurldevice/_api_docs.html @@ -1,10 +1,10 @@

- Register LNURLPoS devices to receive payments in your LNbits wallet.
+ Register LNURLDevice devices to receive payments in your LNbits wallet.
Build your own here - https://github.com/arcbtc/LNURLPoShttps://github.com/arcbtc/bitcoinpos
Created by, Ben Arc POST /lnurlpos/api/v1/lnurlpos /lnurldevice/api/v1/lnurlpos

Headers
{"X-Api-Key": <admin_key>}
@@ -36,10 +36,10 @@
Returns 200 OK (application/json)
- [<lnurlpos_object>, ...] + [<lnurldevice_object>, ...]
Curl example
curl -X POST {{ request.base_url }}api/v1/lnurlpos -d '{"title": + >curl -X POST {{ request.base_url }}api/v1/lnurldevice -d '{"title": <string>, "message":<string>, "currency": <integer>}' -H "Content-type: application/json" -H "X-Api-Key: {{user.wallets[0].adminkey }}" @@ -51,13 +51,13 @@ group="api" dense expand-separator - label="Update lnurlpos" + label="Update lnurldevice" > PUT - /lnurlpos/api/v1/lnurlpos/<lnurlpos_id>
Headers
{"X-Api-Key": <admin_key>}
@@ -67,25 +67,30 @@
Returns 200 OK (application/json)
- [<lnurlpos_object>, ...] + [<lnurldevice_object>, ...]
Curl example
curl -X POST {{ request.base_url - }}api/v1/lnurlpos/<lnurlpos_id> -d ''{"title": <string>, - "message":<string>, "currency": <integer>} -H - "Content-type: application/json" -H "X-Api-Key: + }}api/v1/lnurlpos/<lnurldevice_id> -d ''{"title": + <string>, "message":<string>, "currency": + <integer>} -H "Content-type: application/json" -H "X-Api-Key: {{user.wallets[0].adminkey }}"
- + GET - /lnurlpos/api/v1/lnurlpos/<lnurlpos_id>
Headers
{"X-Api-Key": <invoice_key>}
@@ -95,21 +100,27 @@
Returns 200 OK (application/json)
- [<lnurlpos_object>, ...] + [<lnurldevice_object>, ...]
Curl example
curl -X GET {{ request.base_url - }}api/v1/lnurlpos/<lnurlpos_id> -H "X-Api-Key: {{ + }}api/v1/lnurlpos/<lnurldevice_id> -H "X-Api-Key: {{ user.wallets[0].inkey }}"
- + GET /lnurlpos/api/v1/lnurlpossGET + /lnurldevice/api/v1/lnurlposs
Headers
{"X-Api-Key": <invoice_key>}
@@ -119,11 +130,11 @@
Returns 200 OK (application/json)
- [<lnurlpos_object>, ...] + [<lnurldevice_object>, ...]
Curl example
curl -X GET {{ request.base_url }}api/v1/lnurlposs -H "X-Api-Key: - {{ user.wallets[0].inkey }}" + >curl -X GET {{ request.base_url }}api/v1/lnurldevices -H + "X-Api-Key: {{ user.wallets[0].inkey }}"
@@ -139,7 +150,7 @@ DELETE - /lnurlpos/api/v1/lnurlpos/<lnurlpos_id>
Headers
{"X-Api-Key": <admin_key>}
@@ -148,7 +159,7 @@
Curl example
curl -X DELETE {{ request.base_url - }}api/v1/lnurlpos/<lnurlpos_id> -H "X-Api-Key: {{ + }}api/v1/lnurlpos/<lnurldevice_id> -H "X-Api-Key: {{ user.wallets[0].adminkey }}"
diff --git a/lnbits/extensions/lnurlpos/templates/lnurlpos/error.html b/lnbits/extensions/lnurldevice/templates/lnurldevice/error.html similarity index 100% rename from lnbits/extensions/lnurlpos/templates/lnurlpos/error.html rename to lnbits/extensions/lnurldevice/templates/lnurldevice/error.html diff --git a/lnbits/extensions/lnurlpos/templates/lnurlpos/index.html b/lnbits/extensions/lnurldevice/templates/lnurldevice/index.html similarity index 60% rename from lnbits/extensions/lnurlpos/templates/lnurlpos/index.html rename to lnbits/extensions/lnurldevice/templates/lnurldevice/index.html index 79a6d457a..b51e25568 100644 --- a/lnbits/extensions/lnurlpos/templates/lnurlpos/index.html +++ b/lnbits/extensions/lnurldevice/templates/lnurldevice/index.html @@ -8,8 +8,8 @@ New LNURLPoS instance + @click="formDialoglnurldevice.show = true" + >New LNURLDevice instance @@ -18,7 +18,7 @@
-
lNURLPoS
+
lNURLdevice
@@ -33,7 +33,7 @@ - Export to CSV
@@ -41,10 +41,10 @@