feat: use lnurl lib (#60)

* feat: use lnurl lib
use new lnurl lib from v1.3.0
* mobile display page fix
---------

Co-authored-by: Tiago Vasconcelos <talvasconcelos@gmail.com>
This commit is contained in:
dni ⚡ 2025-09-09 10:53:56 +02:00 committed by GitHub
commit f08060d0f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 125 additions and 93 deletions

View file

@ -2,7 +2,8 @@
"name": "Bolt Cards", "name": "Bolt Cards",
"short_description": "Self custody Bolt Cards with one time LNURLw", "short_description": "Self custody Bolt Cards with one time LNURLw",
"tile": "/boltcards/static/image/boltcard.png", "tile": "/boltcards/static/image/boltcard.png",
"min_lnbits_version": "1.0.0", "version": "1.1.0",
"min_lnbits_version": "1.3.0",
"contributors": [ "contributors": [
{ {
"name": "dni", "name": "dni",

View file

@ -56,12 +56,16 @@ card.card_name }{% endblock %} {% block page %}
:color="hit.spent > 0 ? 'green' : 'grey'" :color="hit.spent > 0 ? 'green' : 'grey'"
/> />
</q-item-section> </q-item-section>
<q-item-section> <q-item-section class="overflow-hidden">
<q-item-label <q-item-label
>ID: ${hit.id} ${refunds.some(r => r.hit_id == hit.id) ? >ID: ${hit.id} ${refunds.some(r => r.hit_id == hit.id) ?
'(Refunded)' : null}</q-item-label '(Refunded)' : null}
> <q-tooltip><span v-text="hit.id"></span></q-tooltip>
<q-item-label caption lines="1">IP: ${hit.ip}</q-item-label> </q-item-label>
<q-item-label caption lines="1"
>IP: ${hit.ip}<q-tooltip
><span v-text="hit.ip"></span></q-tooltip
></q-item-label>
</q-item-section> </q-item-section>
<q-item-section side top> <q-item-section side top>

View file

@ -381,7 +381,6 @@
<div class="col q-mt-lg text-center"> <div class="col q-mt-lg text-center">
<lnbits-qrcode <lnbits-qrcode
:value="qrCodeDialog.data.link" :value="qrCodeDialog.data.link"
class="rounded-borders"
v-show="!qrCodeDialog.wipe" v-show="!qrCodeDialog.wipe"
></lnbits-qrcode> ></lnbits-qrcode>
<p class="text-center" v-show="!qrCodeDialog.wipe"> <p class="text-center" v-show="!qrCodeDialog.wipe">
@ -396,7 +395,6 @@
</p> </p>
<lnbits-qrcode <lnbits-qrcode
:value="qrCodeDialog.data_wipe" :value="qrCodeDialog.data_wipe"
class="rounded-borders"
v-show="qrCodeDialog.wipe" v-show="qrCodeDialog.wipe"
></lnbits-qrcode> ></lnbits-qrcode>
<p class="text-center" v-show="qrCodeDialog.wipe"> <p class="text-center" v-show="qrCodeDialog.wipe">

View file

@ -106,7 +106,11 @@ async def api_card_create(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
) )
card = await create_card(wallet_id=wallet.wallet.id, data=data) 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 return card
@ -117,19 +121,25 @@ async def enable_card(
card_id: str, card_id: str,
enable: bool, enable: bool,
wallet: WalletTypeInfo = Depends(require_admin_key), wallet: WalletTypeInfo = Depends(require_admin_key),
): ) -> Card:
card = await get_card(card_id) card = await get_card(card_id)
if not card: if not card:
raise HTTPException(detail="No card found.", status_code=HTTPStatus.NOT_FOUND) raise HTTPException(detail="No card found.", status_code=HTTPStatus.NOT_FOUND)
if card.wallet != wallet.wallet.id: if card.wallet != wallet.wallet.id:
raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN) raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN)
card = await enable_disable_card(enable=enable, card_id=card_id) card = await enable_disable_card(enable=enable, card_id=card_id)
assert card if not card:
return card.dict() raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Could not update card.",
)
return card
@boltcards_api_router.delete("/api/v1/cards/{card_id}") @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) card = await get_card(card_id)
if not card: 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) raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN)
await delete_card(card_id) await delete_card(card_id)
return "", HTTPStatus.NO_CONTENT
@boltcards_api_router.get("/api/v1/hits") @boltcards_api_router.get("/api/v1/hits")

View file

@ -6,10 +6,20 @@ from urllib.parse import urlparse
import bolt11 import bolt11
from fastapi import APIRouter, HTTPException, Query, Request from fastapi import APIRouter, HTTPException, Query, Request
from lnbits.core.services import create_invoice, pay_invoice from lnbits.core.services import create_invoice, pay_invoice
from lnurl import encode as lnurl_encode from lnurl import (
from lnurl.types import LnurlPayMetadata CallbackUrl,
from loguru import logger LightningInvoice,
from starlette.responses import HTMLResponse LnurlErrorResponse,
LnurlPayActionResponse,
LnurlPayMetadata,
LnurlPayResponse,
LnurlSuccessResponse,
LnurlWithdrawResponse,
Max144Str,
MessageAction,
MilliSatoshi,
)
from pydantic import parse_obj_as
from .crud import ( from .crud import (
create_hit, create_hit,
@ -31,7 +41,9 @@ boltcards_lnurl_router = APIRouter()
# /boltcards/api/v1/scan?p=00000000000000000000000000000000&c=0000000000000000 # /boltcards/api/v1/scan?p=00000000000000000000000000000000&c=0000000000000000
@boltcards_lnurl_router.get("/api/v1/scan/{external_id}") @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 # some wallets send everything as lower case, no bueno
p = p.upper() p = p.upper()
c = c.upper() c = c.upper()
@ -39,27 +51,28 @@ async def api_scan(p, c, request: Request, external_id: str):
counter = b"" counter = b""
card = await get_card_by_external_id(external_id) card = await get_card_by_external_id(external_id)
if not card: if not card:
return {"status": "ERROR", "reason": "No card."} return LnurlErrorResponse(reason="Card not found.")
if not card.enable: if not card.enable:
return {"status": "ERROR", "reason": "Card is disabled."} return LnurlErrorResponse(reason="Card is disabled.")
try: try:
card_uid, counter = decrypt_sun(bytes.fromhex(p), bytes.fromhex(card.k1)) card_uid, counter = decrypt_sun(bytes.fromhex(p), bytes.fromhex(card.k1))
if card.uid.upper() != card_uid.hex().upper(): 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(): 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: except Exception:
return {"status": "ERROR", "reason": "Error decrypting card."} return LnurlErrorResponse(reason="Error decrypting card.")
ctr_int = int.from_bytes(counter, "little") ctr_int = int.from_bytes(counter, "little")
if ctr_int <= card.counter: 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) await update_card_counter(ctr_int, card.id)
# gathering some info for hit record # gathering some info for hit record
assert request.client if not request.client:
return LnurlErrorResponse(reason="Cannot get client info.")
ip = request.client.host ip = request.client.host
if "x-real-ip" in request.headers: if "x-real-ip" in request.headers:
ip = request.headers["x-real-ip"] ip = request.headers["x-real-ip"]
@ -73,27 +86,25 @@ async def api_scan(p, c, request: Request, external_id: str):
for hit in todays_hits: for hit in todays_hits:
hits_amount += hit.amount hits_amount += hit.amount
if hits_amount > int(card.daily_limit): 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) 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 # create a lud17 lnurlp to support lud19, add payLink field of the withdrawRequest
lnurlpay_nonbech32_lud17 = lnurlpay_raw.replace("https://", "lnurlp://").replace( lnurlpay_url = str(request.url_for("boltcards.lnurlp_response", hit_id=hit.id))
"http://", "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))
)
return LnurlWithdrawResponse(
callback=callback_url,
k1=hit.id,
minWithdrawable=MilliSatoshi(1000),
maxWithdrawable=MilliSatoshi(int(card.tx_limit) * 1000),
defaultDescription=f"Boltcard (refund address {pay_link})",
payLink=pay_link, # type: ignore
) )
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( @boltcards_lnurl_router.get(
@ -105,34 +116,32 @@ async def lnurl_callback(
hit_id: str, hit_id: str,
k1: str = Query(None), k1: str = Query(None),
pr: str = Query(None), pr: str = Query(None),
): ) -> LnurlErrorResponse | LnurlSuccessResponse:
# TODO: why no hit_id? its not used why is it passed by url?
logger.debug(f"TODO: why no hit_id? {hit_id}")
if not k1: if not k1:
return {"status": "ERROR", "reason": "Missing K1 token"} return LnurlErrorResponse(reason="Missing K1 token")
if k1 != hit_id:
hit = await get_hit(k1) return LnurlErrorResponse(reason="K1 token does not match.")
hit = await get_hit(hit_id)
if not hit: if not hit:
return { return LnurlErrorResponse(reason="LNURL-withdraw record not found.")
"status": "ERROR",
"reason": "Record not found for this charge (bad k1)",
}
if hit.spent: if hit.spent:
return {"status": "ERROR", "reason": "Payment already claimed"} return LnurlErrorResponse(reason="Payment already claimed.")
if not pr: if not pr:
return {"status": "ERROR", "reason": "Missing payment request"} return LnurlErrorResponse(reason="Missing payment request.")
try: try:
invoice = bolt11.decode(pr) invoice = bolt11.decode(pr)
except bolt11.Bolt11Exception: 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) card = await get_card(hit.card_id)
assert card if not card:
assert invoice.amount_msat, "Invoice amount is missing" return LnurlErrorResponse(reason="Card not found.")
hit = await spend_hit(card_id=hit.id, amount=int(invoice.amount_msat / 1000)) 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: try:
await pay_invoice( await pay_invoice(
wallet_id=card.wallet, wallet_id=card.wallet,
@ -140,9 +149,9 @@ async def lnurl_callback(
max_sat=int(card.tx_limit), max_sat=int(card.tx_limit),
extra={"tag": "boltcards", "hit": hit.id}, extra={"tag": "boltcards", "hit": hit.id},
) )
return {"status": "OK"} return LnurlSuccessResponse()
except Exception as exc: 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 # /boltcards/api/v1/auth?a=00000000000000000000000000000000
@ -222,43 +231,27 @@ async def api_auth_post(a: str, request: Request, data: UIDPost, wipe: bool = Fa
###############LNURLPAY REFUNDS################# ###############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):
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."}
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)
@boltcards_lnurl_router.get( @boltcards_lnurl_router.get(
"/api/v1/lnurlp/cb/{hit_id}", "/api/v1/lnurlp/cb/{hit_id}",
name="boltcards.lnurlp_callback", 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) hit = await get_hit(hit_id)
assert hit
card = await get_card(hit.card_id)
assert card
if not hit: 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( payment = await create_invoice(
wallet_id=card.wallet, wallet_id=card.wallet,
@ -269,5 +262,32 @@ async def lnurlp_callback(hit_id: str, amount: str = Query(None)):
).encode(), ).encode(),
extra={"refund": hit_id}, extra={"refund": hit_id},
) )
action = MessageAction(message=Max144Str("Refunded!"))
invoice = parse_obj_as(LightningInvoice, payment.bolt11)
return LnurlPayActionResponse(pr=invoice, successAction=action)
return {"pr": payment.bolt11, "routes": []}
@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"]])),
)