boltcards/views_lnurl.py
Patrick Mulligan 6d240eaa7b
Some checks failed
lint.yml / feat(lnurl): tap-to-receive (top-up) endpoint (push) Failing after 0s
feat(lnurl): tap-to-receive (top-up) endpoint
Add a deposit counterpart to /scan so a Bolt Card can be tapped to
RECEIVE sats, not only spend. The card only emits its lnurlw (a spend
voucher), so the tap is used as an authenticated identity: the same SUN
p/c that /scan verifies proves possession, and we return an lnurl-PAY
(LUD-06) response for the card's own wallet.

- GET /api/v1/pay/{external_id}?p=&c= — SUN-verified, returns a
  payRequest; the single-use hit is the callback bearer (like k1 for
  withdraw). No daily-limit check (that gates spending); per-deposit max
  is tx_limit.
- GET /api/v1/pay/cb/{hit_id}?amount= — invoices the card wallet.
- Static pay metadata so the LUD-06 description_hash matches.
- Distinct from the LUD-19 refund lnurlp (keyed by a prior scan's hit);
  this is reachable directly by a tap via external_id.
- config.json → 1.1.1-aio.1 (fork of upstream v1.1.1; upstream left its
  config.json at 1.1.0, but the released tag is v1.1.1). README documents
  the endpoint.

Consumed by aiolabs/bitspire #84 (Bolt Card tap-to-receive on cash-in).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 00:52:46 +02:00

396 lines
14 KiB
Python

import json
import secrets
from http import HTTPStatus
from urllib.parse import urlparse
import bolt11
from fastapi import APIRouter, HTTPException, Query, Request
from lnbits.core.services import create_invoice, pay_invoice
from lnurl import (
CallbackUrl,
LightningInvoice,
LnurlErrorResponse,
LnurlPayActionResponse,
LnurlPayMetadata,
LnurlPayResponse,
LnurlSuccessResponse,
LnurlWithdrawResponse,
Max144Str,
MessageAction,
MilliSatoshi,
)
from pydantic import parse_obj_as
from .crud import (
create_hit,
get_card,
get_card_by_external_id,
get_card_by_otp,
get_card_by_uid,
get_hit,
get_hits_today,
spend_hit,
update_card_counter,
update_card_otp,
)
from .models import UIDPost
from .nxp424 import decrypt_sun, get_sun_mac
boltcards_lnurl_router = APIRouter()
# /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
) -> LnurlWithdrawResponse | LnurlErrorResponse:
# some wallets send everything as lower case, no bueno
p = p.upper()
c = c.upper()
card = None
counter = b""
card = await get_card_by_external_id(external_id)
if not card:
return LnurlErrorResponse(reason="Card not found.")
if not card.enable:
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 LnurlErrorResponse(reason="Card UID mis-match.")
if c != get_sun_mac(card_uid, counter, bytes.fromhex(card.k2)).hex().upper():
return LnurlErrorResponse(reason="CMAC does not check.")
except Exception:
return LnurlErrorResponse(reason="Error decrypting card.")
ctr_int = int.from_bytes(counter, "little")
if ctr_int <= card.counter:
return LnurlErrorResponse(reason="This link is already used.")
await update_card_counter(ctr_int, card.id)
# gathering some info for hit record
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"]
elif "x-forwarded-for" in request.headers:
ip = request.headers["x-forwarded-for"]
agent = request.headers["user-agent"] if "user-agent" in request.headers else ""
todays_hits = await get_hits_today(card.id)
hits_amount = 0
for hit in todays_hits:
hits_amount += hit.amount
if hits_amount > int(card.daily_limit):
return LnurlErrorResponse(reason="Max daily limit spent.")
hit = await create_hit(card.id, ip, agent, card.counter, ctr_int)
# 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://"
)
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
)
@boltcards_lnurl_router.get(
"/api/v1/lnurl/cb/{hit_id}",
status_code=HTTPStatus.OK,
name="boltcards.lnurl_callback",
)
async def lnurl_callback(
hit_id: str,
k1: str = Query(None),
pr: str = Query(None),
) -> LnurlErrorResponse | LnurlSuccessResponse:
if not 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 LnurlErrorResponse(reason="LNURL-withdraw record not found.")
if hit.spent:
return LnurlErrorResponse(reason="Payment already claimed.")
if not pr:
return LnurlErrorResponse(reason="Missing payment request.")
try:
invoice = bolt11.decode(pr)
except bolt11.Bolt11Exception:
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)
if not card:
return LnurlErrorResponse(reason="Card not found.")
hit = await spend_hit(card_id=hit.id, amount=int(invoice.amount_msat / 1000))
if not hit:
return LnurlErrorResponse(reason="Failed to update hit as spent.")
try:
await pay_invoice(
wallet_id=card.wallet,
payment_request=pr,
max_sat=int(card.tx_limit),
extra={"tag": "boltcards", "hit": hit.id},
)
return LnurlSuccessResponse()
except Exception as exc:
return LnurlErrorResponse(reason=f"Payment failed - {exc}")
# /boltcards/api/v1/auth?a=00000000000000000000000000000000
@boltcards_lnurl_router.get("/api/v1/auth")
async def api_auth(a, request: Request):
if a == "00000000000000000000000000000000":
response = {"k0": "0" * 32, "k1": "1" * 32, "k2": "2" * 32}
return response
card = await get_card_by_otp(a)
if not card:
raise HTTPException(
detail="Card does not exist.", status_code=HTTPStatus.NOT_FOUND
)
new_otp = secrets.token_hex(16)
await update_card_otp(new_otp, card.id)
lnurlw_base = (
f"{urlparse(str(request.url)).netloc}/boltcards/api/v1/scan/{card.external_id}"
)
response = {
"card_name": card.card_name,
"id": str(1),
"k0": card.k0,
"k1": card.k1,
"k2": card.k2,
"k3": card.k1,
"k4": card.k2,
"lnurlw_base": "lnurlw://" + lnurlw_base,
"protocol_name": "new_bolt_card_response",
"protocol_version": str(1),
}
return response
# /boltcards/api/v1/auth?a=00000000000000000000000000000000
@boltcards_lnurl_router.post("/api/v1/auth")
async def api_auth_post(a: str, request: Request, data: UIDPost, wipe: bool = False):
card = None
if wipe:
card = await get_card_by_otp(a)
else:
if not data.UID:
raise HTTPException(
detail="Missing UID.", status_code=HTTPStatus.BAD_REQUEST
)
card = await get_card_by_uid(data.UID)
if not card:
raise HTTPException(
detail="Card does not exist.", status_code=HTTPStatus.NOT_FOUND
)
new_otp = secrets.token_hex(16)
await update_card_otp(new_otp, card.id)
lnurlw_base = (
f"{urlparse(str(request.url)).netloc}/boltcards/api/v1/scan/{card.external_id}"
)
response = {
"CARD_NAME": card.card_name,
"ID": str(1),
"K0": card.k0,
"K1": card.k1,
"K2": card.k2,
"K3": card.k1,
"K4": card.k2,
"LNURLW_BASE": "LNURLW://" + lnurlw_base,
"LNURLW": "LNURLW://" + lnurlw_base,
"PROTOCOL_NAME": "NEW_BOLT_CARD_RESPONSE",
"PROTOCOL_VERSION": str(1),
}
if wipe:
response["action"] = "wipe"
return response
###############LNURLPAY REFUNDS#################
@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)
) -> LnurlPayActionResponse | LnurlErrorResponse:
hit = await get_hit(hit_id)
if not hit:
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,
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"]])),
)
###############LNURLPAY TAP-TO-RECEIVE (top-up)#################
# Deposit sats to a card's wallet by tapping the card — the receive/cash-in
# counterpart of the /scan withdraw. A Bolt Card only emits its lnurlw (a spend
# voucher), so the tap is used purely as an authenticated identity: the same
# SUN p/c that /scan verifies proves card possession, and we return an
# lnurl-PAY response (LUD-06) for the card's own wallet instead of a withdraw
# voucher. The single-use `hit` acts as the bearer token for the callback,
# mirroring how `k1` bridges the two withdraw steps. Unlike the LUD-19 refund
# lnurlp (keyed by a prior scan's hit), this is reachable directly by a tap.
# The pay metadata MUST be byte-identical between the response below and the
# callback's unhashed_description, or the invoice's description_hash won't match
# (LUD-06). Keep it static.
_TOPUP_METADATA = json.dumps([["text/plain", "Bolt Card top-up"]])
# /boltcards/api/v1/pay/{external_id}?p=<32-hex>&c=<16-hex> (mirrors /scan)
@boltcards_lnurl_router.get(
"/api/v1/pay/{external_id}",
name="boltcards.pay_response",
)
async def api_pay(
p, c, request: Request, external_id: str
) -> LnurlPayResponse | LnurlErrorResponse:
# Mirror /scan's SUN verification exactly (some wallets lowercase p/c).
p = p.upper()
c = c.upper()
card = await get_card_by_external_id(external_id)
if not card:
return LnurlErrorResponse(reason="Card not found.")
if not card.enable:
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 LnurlErrorResponse(reason="Card UID mis-match.")
if c != get_sun_mac(card_uid, counter, bytes.fromhex(card.k2)).hex().upper():
return LnurlErrorResponse(reason="CMAC does not check.")
except Exception:
return LnurlErrorResponse(reason="Error decrypting card.")
ctr_int = int.from_bytes(counter, "little")
if ctr_int <= card.counter:
return LnurlErrorResponse(reason="This link is already used.")
await update_card_counter(ctr_int, card.id)
# Record the tap; the hit id is the single-use bearer for the callback.
# (No daily-limit check here — that gates spending, and this only deposits.)
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"]
elif "x-forwarded-for" in request.headers:
ip = request.headers["x-forwarded-for"]
agent = request.headers["user-agent"] if "user-agent" in request.headers else ""
hit = await create_hit(card.id, ip, agent, card.counter, ctr_int)
callback_url = parse_obj_as(
CallbackUrl, str(request.url_for("boltcards.pay_callback", hit_id=hit.id))
)
return LnurlPayResponse(
callback=callback_url,
minSendable=MilliSatoshi(1000),
maxSendable=MilliSatoshi(int(card.tx_limit) * 1000),
metadata=LnurlPayMetadata(_TOPUP_METADATA),
)
@boltcards_lnurl_router.get(
"/api/v1/pay/cb/{hit_id}",
name="boltcards.pay_callback",
)
async def pay_callback(
hit_id: str, amount: str = Query(None)
) -> LnurlPayActionResponse | LnurlErrorResponse:
hit = await get_hit(hit_id)
if not hit:
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,
amount=int(int(amount) / 1000),
memo=f"Top-up {card.card_name}",
unhashed_description=LnurlPayMetadata(_TOPUP_METADATA).encode(),
extra={"tag": "boltcards", "topup": hit_id},
)
action = MessageAction(message=Max144Str("Topped up!"))
invoice = parse_obj_as(LightningInvoice, payment.bolt11)
return LnurlPayActionResponse(pr=invoice, successAction=action)