From f1bb3a1813b497fdaad43c4230865b1b1854e43a Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Thu, 6 Aug 2026 00:48:02 +0200 Subject: [PATCH] feat(lnurl): tap-to-receive (top-up) endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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.0-aio.1 (aiolabs fork); README documents the endpoint. Consumed by aiolabs/bitspire #84 (Bolt Card tap-to-receive on cash-in). Co-Authored-By: Claude Opus 4.8 --- README.md | 30 ++++++++++++++ config.json | 7 +++- views_lnurl.py | 103 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6f9f244..af7a5f0 100644 --- a/README.md +++ b/README.md @@ -97,3 +97,33 @@ Then fill up the card parameters in the extension. Card Auth key (K0) can be fil - Scan with compatible Wallet This app afaik cannot change the keys. If you cannot change them any other way, leave them empty in the extension dialog and remember you're not secured. Card Auth key (K0) can be omitted anyway. Initical counter can be 0. + +--- + +## aiolabs fork — tap-to-receive (top-up) + +This fork adds a **deposit** counterpart to the `/scan` withdraw, so a Bolt +Card can be tapped to *receive* sats (e.g. the buy flow on a bitSpire ATM), not +only to spend. + +A Bolt Card only ever emits its `lnurlw` (a spend voucher), so the tap is used +purely as an **authenticated identity**: the same NTAG424 SUN `p`/`c` that +`/scan` verifies proves card possession, and the endpoint returns an +**lnurl-pay** (LUD-06) response for the card's *own* wallet instead of a +withdraw voucher. No card re-writing — same NDEF, keys, and `external_id`. + +**Endpoint** (sibling of `/scan`): + +``` +GET /boltcards/api/v1/pay/{external_id}?p=<32-hex>&c=<16-hex> + → LnurlPayResponse { tag:"payRequest", callback, minSendable, maxSendable, metadata } +GET /boltcards/api/v1/pay/cb/{hit_id}?amount= + → LnurlPayActionResponse { pr: } +``` + +- SUN verification, counter monotonicity, and the single-use `hit` bearer token + mirror `/scan` exactly (`hit_id` bridges the two LUD-06 steps like `k1` does + for withdraw). A cloned UID can't misdirect a deposit. +- No daily-limit check (that gates *spending*); per-deposit max is `tx_limit`. +- Distinct from the existing LUD-19 refund `lnurlp` (which is keyed by a prior + scan's `hit`); this is reachable directly by a tap via `external_id`. diff --git a/config.json b/config.json index 0f95271..19f38c2 100644 --- a/config.json +++ b/config.json @@ -2,9 +2,14 @@ "name": "Bolt Cards", "short_description": "Self custody Bolt Cards with one time LNURLw", "tile": "/boltcards/static/image/boltcard.png", - "version": "1.1.0", + "version": "1.1.0-aio.1", "min_lnbits_version": "1.3.0", "contributors": [ + { + "name": "aiolabs", + "uri": "https://git.atitlan.io/aiolabs", + "role": "Fork maintainer (tap-to-receive)" + }, { "name": "dni", "uri": "https://github.com/dni", diff --git a/views_lnurl.py b/views_lnurl.py index d62a87f..bdf3534 100644 --- a/views_lnurl.py +++ b/views_lnurl.py @@ -291,3 +291,106 @@ async def lnurlp_response( 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)