feat(lnurl): tap-to-receive (top-up) endpoint
Some checks failed
lint.yml / feat(lnurl): tap-to-receive (top-up) endpoint (push) Failing after 0s

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>
This commit is contained in:
Patrick Mulligan 2026-08-06 00:48:02 +02:00
commit 6d240eaa7b
3 changed files with 139 additions and 1 deletions

View file

@ -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)