diff --git a/models.py b/models.py
index 59f2fed..ef875e8 100644
--- a/models.py
+++ b/models.py
@@ -13,6 +13,11 @@ class PayLnurlWData(BaseModel):
lnurl: str
+class PayLnurlWPin(BaseModel):
+ pin_token: str = Field(..., min_length=1, max_length=64)
+ pin: str = Field(..., pattern=r"^\d{4}$")
+
+
class CreateWithdrawPay(BaseModel):
pay_link: str
diff --git a/static/components/pin-dialog.js b/static/components/pin-dialog.js
new file mode 100644
index 0000000..f7a9725
--- /dev/null
+++ b/static/components/pin-dialog.js
@@ -0,0 +1,111 @@
+// LUD-24: PIN entry for NFC cards whose withdrawRequest carries a `pinLimit`.
+window.app.component('tpos-pin-dialog', {
+ name: 'tpos-pin-dialog',
+ props: ['show', 'amount', 'attemptsLeft', 'error', 'loading'],
+ emits: ['submit', 'cancel'],
+ data() {
+ return {
+ pin: '',
+ keys: ['1', '2', '3', '4', '5', '6', '7', '8', '9']
+ }
+ },
+ watch: {
+ show(value) {
+ if (value) this.pin = ''
+ }
+ },
+ computed: {
+ errorText() {
+ if (!this.error) return ''
+ if (!this.attemptsLeft) return this.error
+ const word = this.attemptsLeft === 1 ? 'attempt' : 'attempts'
+ return `${this.error} (${this.attemptsLeft} ${word} left)`
+ }
+ },
+ methods: {
+ press(digit) {
+ if (this.loading || this.pin.length >= 4) return
+ this.pin += digit
+ },
+ back() {
+ if (this.loading) return
+ this.pin = this.pin.slice(0, -1)
+ },
+ submit() {
+ if (this.loading || this.pin.length !== 4) return
+ this.$emit('submit', this.pin)
+ this.pin = ''
+ }
+ },
+ template: `
+
+
+
+
Enter card PIN
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cancel
+
+
+
+ `
+})
diff --git a/static/js/tpos.js b/static/js/tpos.js
index a41bd33..c4ce67a 100644
--- a/static/js/tpos.js
+++ b/static/js/tpos.js
@@ -42,6 +42,14 @@ window.app = Vue.createApp({
atmMode: false,
atmToken: '',
nfcTagReading: false,
+ pinDialog: {
+ show: false,
+ token: null,
+ failedAttempts: 0,
+ attemptsLeft: 0,
+ error: '',
+ loading: false
+ },
lnaddressDialog: {
show: false,
lnaddress: ''
@@ -1642,26 +1650,82 @@ window.app = Vue.createApp({
}
)
.then(response => {
- if (!response.data.success) {
- Quasar.Notify.create({
- type: 'negative',
- message: response.data.detail,
- timeout: 0,
- actions: [
- {
- icon: 'close',
- color: 'white',
- round: true,
- handler: () => {
- this.readNfcTag()
- }
- }
- ]
- })
+ if (response.data.pin_required) {
+ this.openPinDialog(response.data.pin_token)
+ } else if (!response.data.success) {
+ this.notifyNfcError(response.data.detail)
+ }
+ })
+ .catch(error => {
+ console.error(error)
+ LNbits.utils.notifyApiError(error)
+ })
+ },
+ notifyNfcError(message) {
+ Quasar.Notify.create({
+ type: 'negative',
+ message,
+ timeout: 0,
+ actions: [
+ {
+ icon: 'close',
+ color: 'white',
+ round: true,
+ handler: () => {
+ this.readNfcTag()
+ }
+ }
+ ]
+ })
+ },
+ // LUD-24: the card asks for a PIN, it allows 3 wrong attempts in total
+ openPinDialog(token) {
+ this.pinDialog = {
+ show: true,
+ token,
+ failedAttempts: 0,
+ attemptsLeft: 0,
+ error: '',
+ loading: false
+ }
+ },
+ closePinDialog() {
+ this.pinDialog.show = false
+ this.pinDialog.token = null
+ this.pinDialog.loading = false
+ },
+ cancelPin() {
+ this.closePinDialog()
+ this.readNfcTag()
+ },
+ submitPin(pin) {
+ this.pinDialog.loading = true
+ this.pinDialog.error = ''
+ return axios
+ .post(`/tpos/api/v1/tposs/${this.tposId}/invoices/pay-pin`, {
+ pin_token: this.pinDialog.token,
+ pin
+ })
+ .then(response => {
+ const data = response.data
+ if (data.success) {
+ this.closePinDialog()
+ } else if (data.pin_retry) {
+ this.pinDialog.failedAttempts += 1
+ this.pinDialog.attemptsLeft = Math.max(
+ 0,
+ 3 - this.pinDialog.failedAttempts
+ )
+ this.pinDialog.error = 'Wrong PIN'
+ this.pinDialog.loading = false
+ } else {
+ this.closePinDialog()
+ this.notifyNfcError(data.detail)
}
})
.catch(error => {
console.error(error)
+ this.closePinDialog()
LNbits.utils.notifyApiError(error)
})
},
diff --git a/templates/tpos/dialogs.html b/templates/tpos/dialogs.html
index 4434caa..05337c8 100644
--- a/templates/tpos/dialogs.html
+++ b/templates/tpos/dialogs.html
@@ -57,6 +57,16 @@
+
+
+
diff --git a/tests/check_static_load.js b/tests/check_static_load.js
index aef1109..ab7512d 100644
--- a/tests/check_static_load.js
+++ b/tests/check_static_load.js
@@ -39,6 +39,7 @@ const pages = [
'static/components/keypad.js',
'static/components/payment-method-selector.js',
'static/components/payment-dialog.js',
+ 'static/components/pin-dialog.js',
'static/components/held-carts-dialog.js',
'static/components/print-dialog.js',
'static/components/receipt.js',
diff --git a/tests/test_api.py b/tests/test_api.py
index 1a9795f..26e211e 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -1352,3 +1352,151 @@ async def get(self, url, **kwargs):
)
assert paid.status_code == 200
assert paid.json()["success"] is True
+
+
+class _FakeLnurlResponse:
+ is_error = False
+
+ def __init__(self, payload):
+ self.payload = payload
+
+ def json(self):
+ return self.payload
+
+
+def _fake_withdraw_client(pin_limit, valid_pin="1234", callbacks=None):
+ """Fake httpx client emulating a LUD-24 service (e.g. boltcards)."""
+ callbacks = callbacks if callbacks is not None else []
+
+ class FakeClient:
+ wrong = 0
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *args):
+ return None
+
+ async def get(self, url, **kwargs):
+ if "callback" not in str(url):
+ request = {
+ "tag": "withdrawRequest",
+ "callback": "https://example.com/callback",
+ "k1": "abc",
+ }
+ if pin_limit is not None:
+ request["pinLimit"] = pin_limit
+ return _FakeLnurlResponse(request)
+ params = kwargs["params"]
+ callbacks.append(params)
+ if params.get("pin") != valid_pin:
+ FakeClient.wrong += 1
+ if FakeClient.wrong >= 3:
+ reason = "Card blocked: too many incorrect PIN attempts"
+ else:
+ reason = "Invalid PIN"
+ return _FakeLnurlResponse({"status": "ERROR", "reason": reason})
+ return _FakeLnurlResponse({"status": "OK"})
+
+ return FakeClient
+
+
+async def _tpos_for_pin_tests(client: AsyncClient, name: str):
+ _user, wallet = await _user_with_tabs(name)
+ create = await client.post(
+ "/tpos/api/v1/tposs",
+ json=_tpos_payload(),
+ headers={"X-API-KEY": wallet.adminkey},
+ )
+ assert create.status_code == 201
+ return create.json()
+
+
+def _patch_withdraw(monkeypatch, fake_client, amount_msat):
+ monkeypatch.setattr(
+ views_payments.httpx, "AsyncClient", lambda *args, **kwargs: fake_client()
+ )
+ monkeypatch.setattr(
+ views_payments,
+ "bolt11_decode",
+ lambda _pr: SimpleNamespace(amount_msat=amount_msat),
+ )
+
+
+@pytest.mark.asyncio
+async def test_pay_invoice_below_pin_limit_needs_no_pin(
+ client: AsyncClient, monkeypatch
+):
+ tpos = await _tpos_for_pin_tests(client, "pinbelowuser")
+ callbacks: list = []
+ _patch_withdraw(
+ monkeypatch, _fake_withdraw_client(10_000_000, callbacks=callbacks), 9_999_000
+ )
+ paid = await client.post(
+ f"/tpos/api/v1/tposs/{tpos['id']}/invoices/lnbc1test/pay",
+ json={"lnurl": "example.com/withdraw"},
+ )
+ assert paid.json()["success"] is False # fake service wants a PIN anyway
+ assert "pin_required" not in paid.json()
+ assert "pin" not in callbacks[0]
+
+
+@pytest.mark.asyncio
+async def test_pay_invoice_pin_flow(client: AsyncClient, monkeypatch):
+ tpos = await _tpos_for_pin_tests(client, "pinflowuser")
+ callbacks: list = []
+ _patch_withdraw(
+ monkeypatch, _fake_withdraw_client(10_000_000, callbacks=callbacks), 10_000_000
+ )
+ first = await client.post(
+ f"/tpos/api/v1/tposs/{tpos['id']}/invoices/lnbc1test/pay",
+ json={"lnurl": "example.com/withdraw"},
+ )
+ data = first.json()
+ assert data["success"] is False
+ assert data["pin_required"] is True
+ assert callbacks == [] # callback must not be called without a PIN
+
+ url = f"/tpos/api/v1/tposs/{tpos['id']}/invoices/pay-pin"
+ wrong = await client.post(url, json={"pin_token": data["pin_token"], "pin": "0000"})
+ assert wrong.json()["success"] is False
+ assert wrong.json()["pin_retry"] is True
+
+ ok = await client.post(url, json={"pin_token": data["pin_token"], "pin": "1234"})
+ assert ok.json()["success"] is True
+ assert callbacks[-1] == {"k1": "abc", "pr": "lnbc1test", "pin": "1234"}
+
+ # token is single use once the payment succeeded
+ again = await client.post(url, json={"pin_token": data["pin_token"], "pin": "1234"})
+ assert again.json()["pin_expired"] is True
+
+
+@pytest.mark.asyncio
+async def test_pay_invoice_pin_card_blocked(client: AsyncClient, monkeypatch):
+ tpos = await _tpos_for_pin_tests(client, "pinblockuser")
+ _patch_withdraw(monkeypatch, _fake_withdraw_client(1000), 5_000)
+ first = await client.post(
+ f"/tpos/api/v1/tposs/{tpos['id']}/invoices/lnbc1test/pay",
+ json={"lnurl": "example.com/withdraw"},
+ )
+ token = first.json()["pin_token"]
+ url = f"/tpos/api/v1/tposs/{tpos['id']}/invoices/pay-pin"
+ for _ in range(2):
+ res = await client.post(url, json={"pin_token": token, "pin": "0000"})
+ assert res.json()["pin_retry"] is True
+ blocked = await client.post(url, json={"pin_token": token, "pin": "0000"})
+ assert blocked.json()["success"] is False
+ assert "pin_retry" not in blocked.json()
+ assert "blocked" in blocked.json()["detail"]
+ gone = await client.post(url, json={"pin_token": token, "pin": "1234"})
+ assert gone.json()["pin_expired"] is True
+
+
+@pytest.mark.asyncio
+async def test_pay_invoice_pin_validation(client: AsyncClient):
+ tpos = await _tpos_for_pin_tests(client, "pinvaliduser")
+ url = f"/tpos/api/v1/tposs/{tpos['id']}/invoices/pay-pin"
+ bad = await client.post(url, json={"pin_token": "x", "pin": "12ab"})
+ assert bad.status_code == 422
+ unknown = await client.post(url, json={"pin_token": "nope", "pin": "1234"})
+ assert unknown.json()["pin_expired"] is True
diff --git a/views_payments.py b/views_payments.py
index ef7afd9..5b98b09 100644
--- a/views_payments.py
+++ b/views_payments.py
@@ -1,9 +1,11 @@
import json
from http import HTTPStatus
from typing import Any, Literal
+from time import time
from uuid import uuid4
import httpx
+from bolt11 import decode as bolt11_decode
from fastapi import APIRouter, HTTPException, Query, Request
from lnbits.core.crud import get_account, get_standalone_payment, get_wallet
from lnbits.core.crud.payments import update_payment, update_payment_checking_id
@@ -26,6 +28,7 @@
CreateTposInvoice,
InventorySale,
PayLnurlWData,
+ PayLnurlWPin,
PrintReceiptRequest,
ReceiptData,
ReceiptDetailsData,
@@ -306,6 +309,50 @@ async def api_tpos_get_latest_invoices(tpos_id: str):
return result
+LNURLW_HEADERS = {"user-agent": "lnbits/tpos"}
+PIN_PENDING_TTL = 120 # seconds a withdrawRequest waits for the PIN
+PIN_PENDING_MAX = 100
+# LUD-24: the card link is single use, so after the first GET the
+# callback/k1 are kept server side until the customer entered the PIN.
+_pin_pending: dict[str, dict[str, Any]] = {}
+
+
+def _pin_pending_cleanup() -> None:
+ now = time()
+ for token in [t for t, v in _pin_pending.items() if v["expires"] < now]:
+ _pin_pending.pop(token, None)
+ while len(_pin_pending) > PIN_PENDING_MAX:
+ _pin_pending.pop(next(iter(_pin_pending)), None)
+
+
+async def _call_withdraw_callback(
+ client: httpx.AsyncClient,
+ callback: str,
+ k1: str,
+ payment_request: str,
+ pin: str | None = None,
+) -> tuple[dict, bool]:
+ """Call the LNURL-withdraw callback. Returns (response, is_error)."""
+ params = {"k1": k1, "pr": payment_request}
+ if pin:
+ params["pin"] = pin
+ r = await client.get(
+ callback, follow_redirects=True, headers=LNURLW_HEADERS, params=params
+ )
+ try:
+ return r.json(), r.is_error
+ except ValueError:
+ return {}, True
+
+
+def _callback_result(resp: dict, is_error: bool) -> dict:
+ if is_error:
+ return {"success": False, "detail": "Error loading callback"}
+ if resp.get("status") == "ERROR":
+ return {"success": False, "detail": resp.get("reason", "")}
+ return {"success": True, "detail": resp}
+
+
@tpos_payments_router.post(
"/api/v1/tposs/{tpos_id}/invoices/{payment_request}/pay", status_code=HTTPStatus.OK
)
@@ -334,45 +381,97 @@ async def api_tpos_pay_invoice(
async with httpx.AsyncClient() as client:
try:
- headers = {"user-agent": "lnbits/tpos"}
- r = await client.get(lnurl, follow_redirects=True, headers=headers)
+ r = await client.get(lnurl, follow_redirects=True, headers=LNURLW_HEADERS)
if r.is_error:
- lnurl_response = {"success": False, "detail": "Error loading"}
- else:
- resp = r.json()
- if resp.get("status") == "ERROR":
- lnurl_response = {
- "success": False,
- "detail": resp.get("reason", ""),
- }
- return lnurl_response
-
- if resp.get("tag") != "withdrawRequest":
- lnurl_response = {"success": False, "detail": "Wrong tag type"}
- else:
- r2 = await client.get(
- resp.get("callback", ""),
- follow_redirects=True,
- headers=headers,
- params={
- "k1": resp.get("k1", ""),
- "pr": payment_request,
- },
- )
- resp2 = r2.json()
- if r2.is_error:
- lnurl_response = {
+ return {"success": False, "detail": "Error loading"}
+ resp = r.json()
+ if resp.get("status") == "ERROR":
+ return {"success": False, "detail": resp.get("reason", "")}
+ if resp.get("tag") != "withdrawRequest":
+ return {"success": False, "detail": "Wrong tag type"}
+
+ callback = resp.get("callback", "")
+ k1 = resp.get("k1", "")
+
+ # LUD-24: PIN is mandatory if amount >= pinLimit
+ pin_limit = resp.get("pinLimit")
+ if pin_limit is not None:
+ try:
+ amount_msat = bolt11_decode(payment_request).amount_msat
+ except Exception:
+ return {"success": False, "detail": "Invalid payment request"}
+ if amount_msat is None or amount_msat >= int(pin_limit):
+ if not callback.lower().startswith("https://"):
+ return {
"success": False,
- "detail": "Error loading callback",
+ "detail": "PIN required but callback is not HTTPS",
}
- elif resp2.get("status") == "ERROR":
- lnurl_response = {"success": False, "detail": resp2["reason"]}
- else:
- lnurl_response = {"success": True, "detail": resp2}
+ _pin_pending_cleanup()
+ pin_token = uuid4().hex
+ _pin_pending[pin_token] = {
+ "tpos_id": tpos_id,
+ "callback": callback,
+ "k1": k1,
+ "payment_request": payment_request,
+ "expires": time() + PIN_PENDING_TTL,
+ }
+ return {
+ "success": False,
+ "pin_required": True,
+ "pin_token": pin_token,
+ "detail": "PIN required",
+ }
+
+ cb_resp, is_error = await _call_withdraw_callback(
+ client, callback, k1, payment_request
+ )
+ return _callback_result(cb_resp, is_error)
except (httpx.ConnectError, httpx.RequestError):
- lnurl_response = {"success": False, "detail": "Unexpected error occurred"}
+ return {"success": False, "detail": "Unexpected error occurred"}
- return lnurl_response
+
+@tpos_payments_router.post(
+ "/api/v1/tposs/{tpos_id}/invoices/pay-pin", status_code=HTTPStatus.OK
+)
+async def api_tpos_pay_invoice_pin(data: PayLnurlWPin, tpos_id: str):
+ tpos = await get_tpos(tpos_id)
+ if not tpos:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist."
+ )
+
+ _pin_pending_cleanup()
+ pending = _pin_pending.get(data.pin_token)
+ if not pending or pending["tpos_id"] != tpos_id:
+ return {
+ "success": False,
+ "pin_expired": True,
+ "detail": "PIN request expired. Please tap the card again.",
+ }
+
+ async with httpx.AsyncClient() as client:
+ try:
+ cb_resp, is_error = await _call_withdraw_callback(
+ client,
+ pending["callback"],
+ pending["k1"],
+ pending["payment_request"],
+ data.pin,
+ )
+ except (httpx.ConnectError, httpx.RequestError):
+ _pin_pending.pop(data.pin_token, None)
+ return {"success": False, "detail": "Unexpected error occurred"}
+
+ result = _callback_result(cb_resp, is_error)
+ if result["success"]:
+ _pin_pending.pop(data.pin_token, None)
+ elif str(result["detail"]).strip().lower() == "invalid pin":
+ # the card allows retries after a wrong PIN
+ result["pin_retry"] = True
+ pending["expires"] = time() + PIN_PENDING_TTL
+ else:
+ _pin_pending.pop(data.pin_token, None)
+ return result
@tpos_payments_router.get(