Skip to content

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

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

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

Conversation

@AxelHamburch

@AxelHamburch AxelHamburch commented May 25, 2026 •

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 LUD-24 (lnurl/luds#290) (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 the PIN via the update API resets the counter; an empty/null PIN keeps the existing PIN and counter (the edit form always blanks the field, "Leave empty to keep existing PIN")

Frontend

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

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 LUD-24 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
  • Edit a card with PIN, change another setting and save without entering a PIN — PIN is kept and still required
  • Edit a card and enter a new PIN — only the new PIN is 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

The UI blanks the PIN field when opening the edit dialog and sends null
when it stays empty. api_card_update() treated that as a request to clear
the PIN, silently disabling PIN protection on every edit. Restore the
stored hash and attempt counter whenever no new PIN is submitted.

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

Copy link
Copy Markdown
Author

Pushed one more commit (f7ca324) after testing on my own LNbits instance:

Fix: editing a card no longer removes the PIN

The edit dialog blanks the PIN field ("Leave empty to keep existing PIN") and sends null when it stays empty. api_card_update() treated that as "clear PIN", so every save without a PIN silently disabled PIN protection.

Now the stored hash and pin_total_attempts are kept whenever no new PIN is submitted. Entering a new PIN still replaces the hash and resets the counter.

Note: a PIN can no longer be removed via the edit dialog, since an empty field always means "keep".

Tested on a VPS LNbits instance: edit without PIN keeps the PIN, a new PIN replaces it.

@AxelHamburch AxelHamburch changed the title feat: PIN limit for BoltCard withdrawals (LUD-XX pinLimit) feat: PIN limit for BoltCard withdrawals (LUD-24 pinLimit) Oct 5, 2026
@AxelHamburch

Copy link
Copy Markdown
Author

Update on the spec this PR implements: the pinLimit proposal has been assigned LUD-24 and is now tracked in lnurl/luds#290 (spec: 24.md). It supersedes lnurl/luds#200, which had been open since 2023 with two approvals.

Why this matters for the boltcards extension: the field is already implemented on the wallet/terminal side and in other services, so LNbits is currently the missing piece for Bolt Card users on LNbits:

  • Terminals / wallets: SwissBitcoinPay boltcard-tools-terminal (merged), BuhoGO (merged), ZapBox
  • Services: boltcard-lndhub (v0.2.0), bitpos, and this PR for LNbits

Cards provisioned against this extension work end-to-end with those terminals and wallets. Without this PR, the PIN flow cannot be used with LNbits-hosted cards.

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