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)