feat(lnurl): /session — one verified tap for a terminal visit
Some checks failed
lint.yml / feat(lnurl): /session — one verified tap for a terminal visit (pull_request) Failing after 0s

GET /api/v1/session/{external_id}?p=&c= for bitSpire tap-to-enter. A tap
yields a single-use SUN, so a terminal that verified it at entry could not
reuse the p/c to move sats later. This verifies once (advancing the
counter like /scan), records one hit, and returns what the rest of the
visit needs: the card wallet's balance + fiat equivalent (wallet currency,
then the instance default; display only), the LUD-03 withdraw step
(callback, k1 = hit) and the LUD-06 top-up step (callback), both keyed by
the hit — the same single-use bearer /scan and /pay already hand out.
Withdraw is withheld with a reason once the daily limit is spent, as
/scan would refuse; top-up stays available.

Tests drive the real decrypt/CMAC path with a SUN encrypted under the
card's keys, and pin /verify + /pay behaviour across the helper refactor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-09-20 17:01:14 +02:00
commit 4e9cd78b59
2 changed files with 332 additions and 0 deletions

View file

@ -450,3 +450,85 @@ async def api_verify(p, c, external_id: str):
"card_name": card.card_name,
}
###############ACCESS-CONTROL SESSION (terminal tap-to-enter)#################
# /boltcards/api/v1/session/{external_id}?p=<32-hex>&c=<16-hex>
# One tap → one session. For a terminal (bitSpire ATM) that unlocks on a card
# tap and lets the holder finish a buy or sell later in the same visit. A tap
# yields a single-use SUN, so anything that verifies it — /scan, /pay, /verify
# — spends it; a terminal that verified at entry could not reuse the p/c to
# move sats afterwards. This endpoint verifies ONCE (advancing the counter,
# exactly like /scan), records ONE hit, and returns everything the rest of the
# visit needs:
# - the card wallet's balance and its fiat equivalent (display only),
# - the LUD-03 second step (withdraw callback, k1 = hit) to pull a payment,
# - the LUD-06 second step (pay callback) to top the wallet up.
# Both callbacks are keyed by the hit — the same single-use bearer /scan and
# /pay already hand out — so the terminal holds no p/c, and the first withdraw
# spends the hit just as it would after a /scan. A top-up leaves it unspent, as
# /pay does. Reasons mirror /scan so a terminal can show them verbatim.
@boltcards_lnurl_router.get("/api/v1/session/{external_id}")
async def api_session(p, c, request: Request, external_id: str):
card, ctr_int, reason = await _authenticate_tap(external_id, p, c)
if not card:
return {"authenticated": False, "reason": reason}
client = _client_info(request)
if not client:
return {"authenticated": False, "reason": "Cannot get client info."}
ip, agent = client
# /scan refuses a withdraw voucher once today's hits exceed the daily limit.
# Mirror that by withholding the withdraw step; the top-up step only
# deposits and stays available, so the session itself is still granted.
todays_hits = await get_hits_today(card.id)
spent_today = sum(hit.amount for hit in todays_hits)
hit = await create_hit(card.id, ip, agent, card.counter, ctr_int)
withdraw = None
withdraw_blocked_reason = None
if spent_today > int(card.daily_limit):
withdraw_blocked_reason = "Max daily limit spent."
else:
withdraw = {
"callback": str(request.url_for("boltcards.lnurl_callback", hit_id=hit.id)),
"k1": hit.id,
"minWithdrawable": 1000,
"maxWithdrawable": int(card.tx_limit) * 1000,
}
pay = {
"callback": str(request.url_for("boltcards.pay_callback", hit_id=hit.id)),
"minSendable": 1000,
"maxSendable": int(card.tx_limit) * 1000,
"metadata": _TOPUP_METADATA,
}
# Balance + fiat, the way the LNbits wallet page shows them: the wallet's
# own currency first (per-wallet setting, LNbits ≥ 1.6), then the
# instance's default accounting currency; no currency → no fiat, and a
# rate failure never fails the session.
wallet = await get_wallet(card.wallet)
balance_msat = int(wallet.balance_msat) if wallet else 0
currency = (getattr(wallet, "currency", None) if wallet else None) or getattr(
settings, "lnbits_default_accounting_currency", None
)
fiat = None
if currency:
try:
fiat = await satoshis_amount_as_fiat(balance_msat / 1000, currency)
except Exception:
fiat = None
return {
"authenticated": True,
"external_id": card.external_id,
"card_name": card.card_name,
"balance_msat": balance_msat,
"currency": currency,
"fiat": fiat,
"withdraw": withdraw,
"withdraw_blocked_reason": withdraw_blocked_reason,
"pay": pay,
}