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
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:
parent
3e614428c0
commit
4e9cd78b59
2 changed files with 332 additions and 0 deletions
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue