From 40d219c84324cd50ce11800150228715ff29e190 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Thu, 4 Sep 2025 08:31:11 +0200 Subject: [PATCH 1/6] feat: use lnurl lib use new lnurl lib from v1.3.0 --- config.json | 3 +- templates/boltcards/index.html | 2 - views_api.py | 21 +++-- views_lnurl.py | 164 +++++++++++++++++++-------------- 4 files changed, 112 insertions(+), 78 deletions(-) diff --git a/config.json b/config.json index a0d9ff7..0f95271 100644 --- a/config.json +++ b/config.json @@ -2,7 +2,8 @@ "name": "Bolt Cards", "short_description": "Self custody Bolt Cards with one time LNURLw", "tile": "/boltcards/static/image/boltcard.png", - "min_lnbits_version": "1.0.0", + "version": "1.1.0", + "min_lnbits_version": "1.3.0", "contributors": [ { "name": "dni", diff --git a/templates/boltcards/index.html b/templates/boltcards/index.html index cd63021..4e2d6e1 100644 --- a/templates/boltcards/index.html +++ b/templates/boltcards/index.html @@ -381,7 +381,6 @@

@@ -396,7 +395,6 @@

diff --git a/views_api.py b/views_api.py index 3bbe6dd..ee75708 100644 --- a/views_api.py +++ b/views_api.py @@ -106,7 +106,11 @@ async def api_card_create( status_code=HTTPStatus.BAD_REQUEST, ) card = await create_card(wallet_id=wallet.wallet.id, data=data) - assert card, "create_card should always return a card" + if not card: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Could not create card.", + ) return card @@ -117,19 +121,25 @@ async def enable_card( card_id: str, enable: bool, wallet: WalletTypeInfo = Depends(require_admin_key), -): +) -> Card: card = await get_card(card_id) if not card: raise HTTPException(detail="No card found.", status_code=HTTPStatus.NOT_FOUND) if card.wallet != wallet.wallet.id: raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN) card = await enable_disable_card(enable=enable, card_id=card_id) - assert card - return card.dict() + if not card: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Could not update card.", + ) + return card @boltcards_api_router.delete("/api/v1/cards/{card_id}") -async def api_card_delete(card_id, wallet: WalletTypeInfo = Depends(require_admin_key)): +async def api_card_delete( + card_id, wallet: WalletTypeInfo = Depends(require_admin_key) +) -> None: card = await get_card(card_id) if not card: @@ -141,7 +151,6 @@ async def api_card_delete(card_id, wallet: WalletTypeInfo = Depends(require_admi raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN) await delete_card(card_id) - return "", HTTPStatus.NO_CONTENT @boltcards_api_router.get("/api/v1/hits") diff --git a/views_lnurl.py b/views_lnurl.py index 01da89e..71e6995 100644 --- a/views_lnurl.py +++ b/views_lnurl.py @@ -5,11 +5,23 @@ import bolt11 from fastapi import APIRouter, HTTPException, Query, Request +from fastapi.responses import HTMLResponse from lnbits.core.services import create_invoice, pay_invoice -from lnurl import encode as lnurl_encode -from lnurl.types import LnurlPayMetadata -from loguru import logger -from starlette.responses import HTMLResponse +from lnurl import ( + CallbackUrl, + LightningInvoice, + Lnurl, + LnurlErrorResponse, + LnurlPayActionResponse, + LnurlPayMetadata, + LnurlPayResponse, + LnurlSuccessResponse, + LnurlWithdrawResponse, + Max144Str, + MessageAction, + MilliSatoshi, +) +from pydantic import parse_obj_as from .crud import ( create_hit, @@ -31,7 +43,9 @@ # /boltcards/api/v1/scan?p=00000000000000000000000000000000&c=0000000000000000 @boltcards_lnurl_router.get("/api/v1/scan/{external_id}") -async def api_scan(p, c, request: Request, external_id: str): +async def api_scan( + p, c, request: Request, external_id: str +) -> LnurlWithdrawResponse | LnurlErrorResponse: # some wallets send everything as lower case, no bueno p = p.upper() c = c.upper() @@ -39,27 +53,28 @@ async def api_scan(p, c, request: Request, external_id: str): counter = b"" card = await get_card_by_external_id(external_id) if not card: - return {"status": "ERROR", "reason": "No card."} + return LnurlErrorResponse(reason="Card not found.") if not card.enable: - return {"status": "ERROR", "reason": "Card is disabled."} + return LnurlErrorResponse(reason="Card is disabled.") try: card_uid, counter = decrypt_sun(bytes.fromhex(p), bytes.fromhex(card.k1)) if card.uid.upper() != card_uid.hex().upper(): - return {"status": "ERROR", "reason": "Card UID mis-match."} + return LnurlErrorResponse(reason="Card UID mis-match.") if c != get_sun_mac(card_uid, counter, bytes.fromhex(card.k2)).hex().upper(): - return {"status": "ERROR", "reason": "CMAC does not check."} + return LnurlErrorResponse(reason="CMAC does not check.") except Exception: - return {"status": "ERROR", "reason": "Error decrypting card."} + return LnurlErrorResponse(reason="Error decrypting card.") ctr_int = int.from_bytes(counter, "little") if ctr_int <= card.counter: - return {"status": "ERROR", "reason": "This link is already used."} + return LnurlErrorResponse(reason="This link is already used.") await update_card_counter(ctr_int, card.id) # gathering some info for hit record - assert request.client + if not request.client: + return LnurlErrorResponse(reason="Cannot get client info.") ip = request.client.host if "x-real-ip" in request.headers: ip = request.headers["x-real-ip"] @@ -73,27 +88,25 @@ async def api_scan(p, c, request: Request, external_id: str): for hit in todays_hits: hits_amount += hit.amount if hits_amount > int(card.daily_limit): - return {"status": "ERROR", "reason": "Max daily limit spent."} + return LnurlErrorResponse(reason="Max daily limit spent.") hit = await create_hit(card.id, ip, agent, card.counter, ctr_int) - # the raw lnurl - lnurlpay_raw = str(request.url_for("boltcards.lnurlp_response", hit_id=hit.id)) - # bech32 encoded lnurl - lnurlpay_bech32 = lnurl_encode(lnurlpay_raw) # create a lud17 lnurlp to support lud19, add payLink field of the withdrawRequest - lnurlpay_nonbech32_lud17 = lnurlpay_raw.replace("https://", "lnurlp://").replace( - "http://", "lnurlp://" + lnurlpay_url = str(request.url_for("boltcards.lnurlp_response", hit_id=hit.id)) + pay_link = Lnurl( + lnurlpay_url.replace("http://", "lnurlp://").replace("https://", "lnurlp://") + ) + callback_url = parse_obj_as( + CallbackUrl, str(request.url_for("boltcards.lnurl_callback", hit_id=hit.id)) + ) + return LnurlWithdrawResponse( + callback=callback_url, + k1=hit.id, + minWithdrawable=MilliSatoshi(1000), + maxWithdrawable=MilliSatoshi(int(card.tx_limit) * 1000), + defaultDescription=f"Boltcard (refund address lnurl://{pay_link.bech32})", + payLink=pay_link, # LUD-19 compatibility ) - - return { - "tag": "withdrawRequest", - "callback": str(request.url_for("boltcards.lnurl_callback", hit_id=hit.id)), - "k1": hit.id, - "minWithdrawable": 1 * 1000, - "maxWithdrawable": int(card.tx_limit) * 1000, - "defaultDescription": f"Boltcard (refund address lnurl://{lnurlpay_bech32})", - "payLink": lnurlpay_nonbech32_lud17, # LUD-19 compatibility - } @boltcards_lnurl_router.get( @@ -105,34 +118,32 @@ async def lnurl_callback( hit_id: str, k1: str = Query(None), pr: str = Query(None), -): - # TODO: why no hit_id? its not used why is it passed by url? - logger.debug(f"TODO: why no hit_id? {hit_id}") +) -> LnurlErrorResponse | LnurlSuccessResponse: if not k1: - return {"status": "ERROR", "reason": "Missing K1 token"} - - hit = await get_hit(k1) + return LnurlErrorResponse(reason="Missing K1 token") + if k1 != hit_id: + return LnurlErrorResponse(reason="K1 token does not match.") + hit = await get_hit(hit_id) if not hit: - return { - "status": "ERROR", - "reason": "Record not found for this charge (bad k1)", - } + return LnurlErrorResponse(reason="LNURL-withdraw record not found.") if hit.spent: - return {"status": "ERROR", "reason": "Payment already claimed"} + return LnurlErrorResponse(reason="Payment already claimed.") if not pr: - return {"status": "ERROR", "reason": "Missing payment request"} + return LnurlErrorResponse(reason="Missing payment request.") try: invoice = bolt11.decode(pr) except bolt11.Bolt11Exception: - return {"status": "ERROR", "reason": "Failed to decode payment request"} - + return LnurlErrorResponse(reason="Failed to decode payment request.") + if not invoice.amount_msat: + return LnurlErrorResponse(reason="Invoice has no amount.") card = await get_card(hit.card_id) - assert card - assert invoice.amount_msat, "Invoice amount is missing" + if not card: + return LnurlErrorResponse(reason="Card not found.") hit = await spend_hit(card_id=hit.id, amount=int(invoice.amount_msat / 1000)) - assert hit + if not hit: + return LnurlErrorResponse(reason="Failed to update hit as spent.") try: await pay_invoice( wallet_id=card.wallet, @@ -140,9 +151,9 @@ async def lnurl_callback( max_sat=int(card.tx_limit), extra={"tag": "boltcards", "hit": hit.id}, ) - return {"status": "OK"} + return LnurlSuccessResponse() except Exception as exc: - return {"status": "ERROR", "reason": f"Payment failed - {exc}"} + return LnurlErrorResponse(reason=f"Payment failed - {exc}") # /boltcards/api/v1/auth?a=00000000000000000000000000000000 @@ -229,36 +240,50 @@ async def api_auth_post(a: str, request: Request, data: UIDPost, wipe: bool = Fa response_class=HTMLResponse, name="boltcards.lnurlp_response", ) -async def lnurlp_response(req: Request, hit_id: str): +async def lnurlp_response( + req: Request, hit_id: str +) -> LnurlPayResponse | LnurlErrorResponse: hit = await get_hit(hit_id) - assert hit - card = await get_card(hit.card_id) - assert card if not hit: - return {"status": "ERROR", "reason": "LNURL-pay record not found."} + return LnurlErrorResponse(reason="LNURL-pay hit not found.") + card = await get_card(hit.card_id) + if not card: + return LnurlErrorResponse(reason="Card not found.") if not card.enable: - return {"status": "ERROR", "reason": "Card is disabled."} - pay_response = { - "tag": "payRequest", - "callback": str(req.url_for("boltcards.lnurlp_callback", hit_id=hit_id)), - "metadata": LnurlPayMetadata(json.dumps([["text/plain", "Refund"]])), - "minSendable": 1 * 1000, - "maxSendable": int(card.tx_limit) * 1000, - } - return json.dumps(pay_response) + return LnurlErrorResponse(reason="Card is disabled.") + + callback_url = parse_obj_as( + CallbackUrl, str(req.url_for("boltcards.lnurlp_callback", hit_id=hit_id)) + ) + return LnurlPayResponse( + callback=callback_url, + minSendable=MilliSatoshi(1000), + maxSendable=MilliSatoshi(int(card.tx_limit) * 1000), + metadata=LnurlPayMetadata(json.dumps([["text/plain", "Refund"]])), + ) @boltcards_lnurl_router.get( "/api/v1/lnurlp/cb/{hit_id}", name="boltcards.lnurlp_callback", ) -async def lnurlp_callback(hit_id: str, amount: str = Query(None)): +async def lnurlp_callback( + hit_id: str, amount: str = Query(None) +) -> LnurlPayActionResponse | LnurlErrorResponse: hit = await get_hit(hit_id) - assert hit - card = await get_card(hit.card_id) - assert card if not hit: - return {"status": "ERROR", "reason": "LNURL-pay record not found."} + return LnurlErrorResponse(reason="LNURL-pay record not found.") + card = await get_card(hit.card_id) + if not card: + return LnurlErrorResponse(reason="Card not found.") + if not card.enable: + return LnurlErrorResponse(reason="Card is disabled.") + if not amount: + return LnurlErrorResponse(reason="Missing amount.") + if int(amount) < 1000: + return LnurlErrorResponse(reason="Amount too low.") + if int(amount) > int(card.tx_limit) * 1000: + return LnurlErrorResponse(reason="Amount too high.") payment = await create_invoice( wallet_id=card.wallet, @@ -269,5 +294,6 @@ async def lnurlp_callback(hit_id: str, amount: str = Query(None)): ).encode(), extra={"refund": hit_id}, ) - - return {"pr": payment.bolt11, "routes": []} + action = MessageAction(message=Max144Str("Refunded!")) + invoice = parse_obj_as(LightningInvoice, payment.bolt11) + return LnurlPayActionResponse(pr=invoice, successAction=action) From eb2f737bbf6dcfaf6e636766b435ddafe740d51e Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Mon, 8 Sep 2025 17:45:57 +0100 Subject: [PATCH 2/6] mobile display page fix --- templates/boltcards/display.html | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/templates/boltcards/display.html b/templates/boltcards/display.html index bc23b3e..3e43d6a 100644 --- a/templates/boltcards/display.html +++ b/templates/boltcards/display.html @@ -56,12 +56,16 @@ :color="hit.spent > 0 ? 'green' : 'grey'" /> - + ID: ${hit.id} ${refunds.some(r => r.hit_id == hit.id) ? - '(Refunded)' : null} - IP: ${hit.ip} + '(Refunded)' : null} + + + IP: ${hit.ip} From 6d7b7df0661f53b69dfa8f3ff3bf0e1a38ce36e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Tue, 9 Sep 2025 09:56:04 +0200 Subject: [PATCH 3/6] fixup! --- views_lnurl.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/views_lnurl.py b/views_lnurl.py index 71e6995..7909eed 100644 --- a/views_lnurl.py +++ b/views_lnurl.py @@ -10,7 +10,6 @@ from lnurl import ( CallbackUrl, LightningInvoice, - Lnurl, LnurlErrorResponse, LnurlPayActionResponse, LnurlPayMetadata, @@ -93,9 +92,7 @@ async def api_scan( # create a lud17 lnurlp to support lud19, add payLink field of the withdrawRequest lnurlpay_url = str(request.url_for("boltcards.lnurlp_response", hit_id=hit.id)) - pay_link = Lnurl( - lnurlpay_url.replace("http://", "lnurlp://").replace("https://", "lnurlp://") - ) + pay_link = lnurlpay_url.replace("http://", "lnurlp://").replace("https://", "lnurlp://") callback_url = parse_obj_as( CallbackUrl, str(request.url_for("boltcards.lnurl_callback", hit_id=hit.id)) ) @@ -104,8 +101,8 @@ async def api_scan( k1=hit.id, minWithdrawable=MilliSatoshi(1000), maxWithdrawable=MilliSatoshi(int(card.tx_limit) * 1000), - defaultDescription=f"Boltcard (refund address lnurl://{pay_link.bech32})", - payLink=pay_link, # LUD-19 compatibility + defaultDescription=f"Boltcard (refund address {pay_link})", + payLink=pay_link, # type: ignore LUD-19 compatibility ) From d4cd5b3397d512ab5db12d4f5429fc2b063c9b56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Tue, 9 Sep 2025 10:44:04 +0200 Subject: [PATCH 4/6] updates --- views_lnurl.py | 60 +++++++++++++++++++++++--------------------------- 1 file changed, 27 insertions(+), 33 deletions(-) diff --git a/views_lnurl.py b/views_lnurl.py index 7909eed..879f697 100644 --- a/views_lnurl.py +++ b/views_lnurl.py @@ -20,6 +20,7 @@ MessageAction, MilliSatoshi, ) +from loguru import logger from pydantic import parse_obj_as from .crud import ( @@ -230,36 +231,6 @@ async def api_auth_post(a: str, request: Request, data: UIDPost, wipe: bool = Fa ###############LNURLPAY REFUNDS################# - - -@boltcards_lnurl_router.get( - "/api/v1/lnurlp/{hit_id}", - response_class=HTMLResponse, - name="boltcards.lnurlp_response", -) -async def lnurlp_response( - req: Request, hit_id: str -) -> LnurlPayResponse | LnurlErrorResponse: - hit = await get_hit(hit_id) - if not hit: - return LnurlErrorResponse(reason="LNURL-pay hit not found.") - card = await get_card(hit.card_id) - if not card: - return LnurlErrorResponse(reason="Card not found.") - if not card.enable: - return LnurlErrorResponse(reason="Card is disabled.") - - callback_url = parse_obj_as( - CallbackUrl, str(req.url_for("boltcards.lnurlp_callback", hit_id=hit_id)) - ) - return LnurlPayResponse( - callback=callback_url, - minSendable=MilliSatoshi(1000), - maxSendable=MilliSatoshi(int(card.tx_limit) * 1000), - metadata=LnurlPayMetadata(json.dumps([["text/plain", "Refund"]])), - ) - - @boltcards_lnurl_router.get( "/api/v1/lnurlp/cb/{hit_id}", name="boltcards.lnurlp_callback", @@ -286,11 +257,34 @@ async def lnurlp_callback( wallet_id=card.wallet, amount=int(int(amount) / 1000), memo=f"Refund {hit_id}", - unhashed_description=LnurlPayMetadata( - json.dumps([["text/plain", "Refund"]]) - ).encode(), extra={"refund": hit_id}, ) action = MessageAction(message=Max144Str("Refunded!")) invoice = parse_obj_as(LightningInvoice, payment.bolt11) return LnurlPayActionResponse(pr=invoice, successAction=action) + + +@boltcards_lnurl_router.get( + "/api/v1/lnurlp/{hit_id}", + name="boltcards.lnurlp_response", +) +async def lnurlp_response( + req: Request, hit_id: str +) -> LnurlPayResponse | LnurlErrorResponse: + hit = await get_hit(hit_id) + if not hit: + return LnurlErrorResponse(reason="LNURL-pay hit not found.") + card = await get_card(hit.card_id) + if not card: + return LnurlErrorResponse(reason="Card not found.") + if not card.enable: + return LnurlErrorResponse(reason="Card is disabled.") + callback_url = parse_obj_as( + CallbackUrl, str(req.url_for("boltcards.lnurlp_callback", hit_id=hit_id)) + ) + return LnurlPayResponse( + callback=callback_url, + minSendable=MilliSatoshi(1000), + maxSendable=MilliSatoshi(int(card.tx_limit) * 1000), + metadata=LnurlPayMetadata(json.dumps([["text/plain", "Refund"]])), + ) From 3f9ea5607f29b85b0ee7d12eff643543f52ace34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Tue, 9 Sep 2025 10:45:28 +0200 Subject: [PATCH 5/6] revert unhashed --- views_lnurl.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/views_lnurl.py b/views_lnurl.py index 879f697..ea91607 100644 --- a/views_lnurl.py +++ b/views_lnurl.py @@ -257,6 +257,9 @@ async def lnurlp_callback( wallet_id=card.wallet, amount=int(int(amount) / 1000), memo=f"Refund {hit_id}", + unhashed_description=LnurlPayMetadata( + json.dumps([["text/plain", "Refund"]]) + ).encode(), extra={"refund": hit_id}, ) action = MessageAction(message=Max144Str("Refunded!")) From 7450405bc3b867861d9507f50a5f9682867bb569 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Tue, 9 Sep 2025 10:51:38 +0200 Subject: [PATCH 6/6] fixup! --- views_lnurl.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/views_lnurl.py b/views_lnurl.py index ea91607..d62a87f 100644 --- a/views_lnurl.py +++ b/views_lnurl.py @@ -5,7 +5,6 @@ import bolt11 from fastapi import APIRouter, HTTPException, Query, Request -from fastapi.responses import HTMLResponse from lnbits.core.services import create_invoice, pay_invoice from lnurl import ( CallbackUrl, @@ -20,7 +19,6 @@ MessageAction, MilliSatoshi, ) -from loguru import logger from pydantic import parse_obj_as from .crud import ( @@ -93,7 +91,9 @@ async def api_scan( # create a lud17 lnurlp to support lud19, add payLink field of the withdrawRequest lnurlpay_url = str(request.url_for("boltcards.lnurlp_response", hit_id=hit.id)) - pay_link = lnurlpay_url.replace("http://", "lnurlp://").replace("https://", "lnurlp://") + pay_link = lnurlpay_url.replace("http://", "lnurlp://").replace( + "https://", "lnurlp://" + ) callback_url = parse_obj_as( CallbackUrl, str(request.url_for("boltcards.lnurl_callback", hit_id=hit.id)) ) @@ -103,7 +103,7 @@ async def api_scan( minWithdrawable=MilliSatoshi(1000), maxWithdrawable=MilliSatoshi(int(card.tx_limit) * 1000), defaultDescription=f"Boltcard (refund address {pay_link})", - payLink=pay_link, # type: ignore LUD-19 compatibility + payLink=pay_link, # type: ignore )