Skip to content

feat: PIN limit for BoltCard withdrawals (LUD-XX pinLimit)#67

Open
AxelHamburch wants to merge 8 commits into
lnbits:mainfrom
AxelHamburch:feature/pin-limit
Open

feat: PIN limit for BoltCard withdrawals (LUD-XX pinLimit)#67
AxelHamburch wants to merge 8 commits into
lnbits:mainfrom
AxelHamburch:feature/pin-limit

Conversation

@AxelHamburch

Copy link
Copy Markdown

feat: PIN limit for BoltCard withdrawals

Adds optional PIN protection for BoltCard withdrawals above a configurable threshold, implementing the pinLimit extension to LNURL-withdraw as proposed in lnurl/luds#290 (draft spec).

What changed

Backend

  • New fields on Card: pin_limit (sat threshold), pin (PBKDF2-hashed), pin_total_attempts
  • New field on Hit: pin_attempts (per-tap wrong attempts)
  • PIN is hashed with PBKDF2-HMAC-SHA256 using the card ID as salt; the plaintext is never stored
  • withdrawRequest response includes pinLimit (in msat) when set — wallets that support it show a PIN pad
  • Callback (lnurl_callback) validates the PIN when the invoice amount >= pin_limit:
    • Missing PIN -> "PIN required."
    • Wrong PIN -> "Invalid PIN" + card-level counter incremented
    • 3 cumulative wrong attempts -> hit invalidated + card disabled (enable = false)
  • Re-enabling the card via the admin API resets pin_total_attempts to 0
  • Changing or removing the PIN via the update API also resets the counter

Frontend

  • PIN and PIN-limit fields in the card create/edit form
  • 4-digit validation, sat/msat conversion, graceful handling of null values

DB migrations

  • pin_limit, pin, pin_total_attempts columns on boltcards.cards
  • pin_attempts column on boltcards.hits

Screenshots

LNbits — PIN threshold and PIN field in card settings
LNbits PIN limit configuration

ZapBox Touch 3.5" — PIN entry screen on the device
ZapBox Touch 3.5 PIN screen

Spec reference

This implementation follows the draft LUD-XX pinLimit specification:

The pinLimit field in the withdrawRequest response is expressed in millisatoshi, consistent with all other amount fields in the LNURL spec.

Live demo / testing hardware

A working end-to-end implementation already exists and can be tested today. The following setup is required:

  • Server: LNbits instance with the ZapBox Extension v2.2.0 active — this is the client-side wallet that implements the pinLimit flow and renders the PIN pad
  • Hardware: ESP32-S3 with 3.5" touch display (JC3248W535C), flashed with the matching ZapBox firmware via the web installer at installer.zapbox.space/touch3.5 — no toolchain needed, flashes directly in the browser

This lets reviewers observe the full PIN entry UX on real hardware before the LUD is finalised.

Test plan

  • Create card without PIN — withdraw works as before
  • Create card with PIN limit (e.g. 1000 sat) and PIN — small payment (< limit) passes without PIN
  • Payment >= limit with correct PIN succeeds and resets attempt counter
  • Payment >= limit with wrong PIN returns error; third wrong PIN disables card
  • Re-enable card via API, confirm pin_total_attempts resets and card works again
  • Update card with new PIN — old PIN no longer accepted

AxelHamburch and others added 8 commits May 20, 2026 20:13
Implements optional PIN protection per draft LUD-XX spec:
- migrations: add pin_limit, pin to cards; pin_attempts to hits
- models: extend Card, CreateCardData, Hit with PIN fields
- crud: hash_pin/verify_pin via pbkdf2_hmac; invalidate_hit; update_hit_pin_attempts
- views_lnurl: include pinLimit in withdrawRequest response; validate PIN in callback
- views_api: hash PIN on card create/update; clear PIN when set to null
After 3 wrong PINs the card is marked pin_blocked=True in the DB.
Subsequent taps immediately return 'Card blocked' without offering
new attempts. Admin resets the block by saving the card (PIN update
or clear in the boltcards UI).

- migrations.py: m004_add_pin_blocked -- adds pin_blocked column
- models.py: pin_blocked: bool = False on Card
- crud.py: block_card() / unblock_card() helpers
- views_lnurl.py: check pin_blocked in api_scan; call block_card()
  after 3rd wrong PIN in lnurl_callback
- views_api.py: reset pin_blocked=False on PIN change/clear

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
After 3 incorrect PIN attempts, the card is set to enable=False via
the existing enable_disable_card(). The user re-enables it manually
via the DISABLE/ENABLE button in the boltcards UI -- no separate
reset mechanism needed.

The m004_add_pin_blocked migration column stays in the DB but is
no longer used by the model or logic.

- models.py: remove pin_blocked field
- crud.py: remove block_card() / unblock_card()
- views_lnurl.py: call enable_disable_card(False) on 3rd wrong PIN;
  remove pin_blocked check in api_scan
- views_api.py: remove unblock_card import and pin_blocked resets

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously pin_attempts was tracked per hit only. Cancelling and
re-tapping the card created a new hit with pin_attempts=0, allowing
unlimited PIN attempts across taps.

Adds pin_total_attempts on the Card (persisted in DB). Every wrong
PIN increments this counter regardless of cancel/re-tap. On the 3rd
wrong attempt total the card is disabled. Counter resets on correct
PIN or when admin changes/clears the PIN.

- migrations.py: m005_add_card_pin_attempts
- models.py: pin_total_attempts: int = 0 on Card
- crud.py: increment_card_pin_attempts() / reset_card_pin_attempts()
- views_lnurl.py: use card counter; reset on successful payment
- views_api.py: reset counter on PIN change/clear

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
After an admin re-enables a blocked card via the toggle, the
pin_total_attempts counter was still >= 3. The first wrong PIN on
the next tap immediately re-blocked the card.

Now enable_disable_card(True) also resets pin_total_attempts to 0
so the card genuinely gets a fresh start.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@AxelHamburch

Copy link
Copy Markdown
Author

@dni @arcbtc

Is there anything else I can do to help promote this PR?

Feel free to try out the feature using my LNbits server, 21mio.space, and the BuhoGo wallet from Dr.Shift. It works great and really adds value to the Bolt Card.

image

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