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
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:
parent
407506ad9f
commit
6d240eaa7b
3 changed files with 139 additions and 1 deletions
103
views_lnurl.py
103
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue