Skip to content

feat: support LUD-24 pinLimit for NFC card payments - #260

Open
AxelHamburch wants to merge 1 commit into
lnbits:mainfrom
AxelHamburch:pinLimit
Open

AxelHamburch wants to merge 1 commit into
lnbits:mainfrom
AxelHamburch:pinLimit

Conversation

@AxelHamburch

@AxelHamburch AxelHamburch commented Oct 18, 2026 •

Copy link
Copy Markdown

Summary

TPoS can now pay with NFC cards whose LNURL-withdraw service requires a PIN
for larger amounts (LUD-24, optional pinLimit field in the withdrawRequest).

Before this change, TPoS always called the withdraw callback with only k1 and
pr. For a card with a PIN limit, the service answered PIN required. and the
customer could not pay. With this PR the customer is asked for the PIN on the
TPoS screen.

Services that do not send pinLimit (and amounts below the limit) behave
exactly as before.

Background

  • LUD-24 adds an optional pinLimit (in msat) to the LUD-03 withdrawRequest.
    If the invoice amount is >= pinLimit, the wallet/terminal must collect a
    4-digit PIN and send it as pin= in the callback. Without it the service
    rejects the request.
  • It is aimed at battery-less NFC devices (Bolt Cards) where a lost card could
    otherwise be spent without the owner knowing.
  • The service side is implemented for the LNbits Boltcards extension in
    lnbits/boltcards#67 and
    lnbits/boltcards#68.
  • The LUD itself is still a draft (PR #290, not merged yet). Terminals that do
    not support it simply cannot complete such a payment, which is intended by
    the spec.

How it works

  1. The customer taps the card, TPoS reads the LNURL and fetches the
    withdrawRequest (server side, as before).
  2. If pinLimit is present, TPoS decodes the invoice (including tip) and
    compares its amount with pinLimit.
    • Below the limit: callback is called as before.
    • At or above the limit: the callback is not called. TPoS returns
      pin_required and a one-time pin_token to the frontend.
  3. The frontend opens a PIN dialog showing the amount and a 4-digit keypad.
  4. The PIN is posted to the new endpoint, TPoS calls the callback with
    k1, pr and pin.
    • Wrong PIN (Invalid PIN): dialog stays open, shows
      Wrong PIN (2 attempts left), then (1 attempt left).
    • Card blocked: too many incorrect PIN attempts or any other error:
      dialog closes and the error is shown, as for other NFC errors.
    • Success: dialog closes, payment is confirmed through the existing
      payment websocket/checker.
    • Cancel: dialog closes and NFC reading restarts.

Why a server side pending entry?

The card link is single use (the SUN counter is consumed by the first GET), so
the withdrawRequest cannot be fetched again after the PIN was entered.
TPoS therefore keeps callback, k1 and the invoice for up to 2 minutes in
memory, referenced by a random token. The client never sends a callback URL,
so it cannot be used to make the server call arbitrary URLs (no SSRF). A wrong
PIN refreshes the 2 minute window, the entry is removed on success, on any
other error and when it expires. The store is limited to 100 entries.

The Boltcards service keeps the hit valid after a wrong PIN, so retrying the
callback with the same k1 works. The "3 wrong PINs" limit is enforced by the
service. TPoS only displays the attempts left for the dialog that is currently
open and cannot know the card's total count.

Changes

  • views_payments.py
    • api_tpos_pay_invoice: reads pinLimit, decodes the invoice with
      bolt11 and returns pin_required + pin_token when needed.
      Callback logic moved into small helpers
      (_call_withdraw_callback, _callback_result).
    • New POST /api/v1/tposs/{tpos_id}/invoices/pay-pin.
    • A pinLimit request with a non-HTTPS callback is rejected
      (LUD-24 requires HTTPS).
  • models.py: PayLnurlWPin (pin_token, pin validated as exactly 4 digits).
  • static/components/pin-dialog.js: new tpos-pin-dialog component
    (masked input, touch friendly keypad, amount shown on the same screen as
    required by LUD-24).
  • static/js/tpos.js, templates/tpos/dialogs.html, templates/tpos/tpos.html:
    PIN dialog state and wiring; the NFC error notification was extracted into
    notifyNfcError and reused.
  • Tests: new API tests and the static load check includes the new component.

No database migration, no new dependency (bolt11 ships with LNbits).
ATM mode is not affected (it uses payLink).

Testing

Try it yourself

A public test server with the full functionality (Boltcards PRs #67 and #68
plus this PR in TPoS) is available at https://21mio.space/:

  1. Create an account on https://21mio.space/ with the temporary
    invitation code pinLimit.
  2. Enable the Boltcards and TPoS extensions.
  3. In Boltcards create a card and set a PIN limit and a 4-digit PIN.
  4. In TPoS create a terminal, open it, enter an amount at or above the PIN limit
    and tap the card.

Manual test

Manually tested on a production-like LNbits server with the Boltcards
extension (PRs #67 / #68) and a Bolt Card with a PIN limit:

  • Amount below pinLimit: payment works without PIN, as before.
  • Amount at/above pinLimit: PIN dialog appears, correct PIN pays.
  • Wrong PIN: error with remaining attempts, dialog stays open.
  • Three wrong PINs: card is blocked, dialog closes with the service error.
  • Cancel returns to NFC reading.
  • Card without pinLimit / other LNURL-withdraw services: unchanged.

Automated:

  • tests/test_api.py: below the limit (no PIN sent), PIN flow with retry and
    success, single-use token, blocked card, input validation (non 4-digit PIN,
    unknown token).
  • node tests/check_static_load.js passes.

Screenshots

TPoS (this PR) and the Boltcards extension (PIN settings, blocked card):


1. Invoice, waiting for the card

2. Amount ≥ pinLimit: PIN dialog

3. Correct PIN: invoice paid

4. Wrong PIN: 2 attempts left

5. Wrong PIN: 1 attempt left

6. Third wrong PIN: card blocked

7. Next tap: card is disabled

Boltcards: PIN limit and PIN

Boltcards: blocked card (disabled)

Boltcards: re-enable the card

Notes for reviewers

  • The pending store is in memory, which fits a single LNbits process. With
    multiple workers, the PIN request could land on a different worker and the
    user would be asked to tap the card again (pin_expired). Persisting it in
    the DB is possible if you prefer that.
  • Only the 4-digit format and the amount comparison are decided by TPoS; PIN
    verification and blocking stay entirely with the service.

When a withdrawRequest carries pinLimit and the invoice amount reaches it,
TPoS asks for a 4-digit PIN instead of calling the callback without one.
The single-use card link is not re-fetched: callback/k1 are kept server
side in a short-lived pending entry referenced by a token.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant