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

View file

@ -97,3 +97,33 @@ Then fill up the card parameters in the extension. Card Auth key (K0) can be fil
- Scan with compatible Wallet - 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. 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=<msat>
→ LnurlPayActionResponse { pr:<bolt11 on the card wallet> }
```
- 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`.

View file

@ -2,9 +2,14 @@
"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",
"version": "1.1.0", "version": "1.1.0-aio.1",
"min_lnbits_version": "1.3.0", "min_lnbits_version": "1.3.0",
"contributors": [ "contributors": [
{
"name": "aiolabs",
"uri": "https://git.atitlan.io/aiolabs",
"role": "Fork maintainer (tap-to-receive)"
},
{ {
"name": "dni", "name": "dni",
"uri": "https://github.com/dni", "uri": "https://github.com/dni",

View file

@ -291,3 +291,106 @@ async def lnurlp_response(
maxSendable=MilliSatoshi(int(card.tx_limit) * 1000), maxSendable=MilliSatoshi(int(card.tx_limit) * 1000),
metadata=LnurlPayMetadata(json.dumps([["text/plain", "Refund"]])), 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)