A credit-card-shaped, standalone Lightning wallet — eInk display, ESP32, WiFi, NFC,
USB-C, no battery, as thin as possible. Concept sketch: lnurl-card.jpg
("LNURL CASH" / "C4RD").
Unlike its sibling repo lnurl-vault — an ESP32-S3
firmware that is deliberately a dumb, offline vault with no networking of its own, paired to a
browser over USB-CDC/BLE — lnurl-card is a standalone wallet that makes its own LNURL HTTP
calls over WiFi, while still supporting the existing paired-companion mode for compatibility. It
also adds a capability lnurl-vault never had at all: NFC, in two roles — a tap-to-pay passive tag
that must work with zero board power, and an active reader/writer for provisioning/reading
other tags.
This is a real, money-custody device headed to PCB fab + assembly (JLCPCB/PCBWay-style PCBA). The hardware design below is an engineering spec and KiCad skeleton — routing (especially the NFC antennas) is intentionally left for a real KiCad session, DRC/ERC, and ideally a second set of EE eyes before anything is sent to a fab.
Confirmed design decisions:
- NFC must work unplugged. A passive tag chip, powered only by the reader's field, holds the tap-to-pay payload. The ESP32 rewrites it when online.
- Input is capacitive touch, not mechanical buttons — zero added thickness, matches "as thin as possible."
One correction made during research, not present in the original sketch: NTAG 424 DNA (the
Boltcard-standard chip, chosen for the passive tag) has no I2C interface — it's
contactless-only. The ESP32 cannot write it over a wired bus. It can only be (re)written the way
Boltcard provisioning already works in the wild (see
bitcoin-ring/Bolty, an ESP32+PN532 Boltcard
provisioner — direct prior art): an active NFC front-end performs an RF write while the tag is in
its field. For a self-contained card that means the passive tag's antenna and the active
front-end's antenna both live inside the same PCB, near-field coupled to each other. This is the
single hardest EE problem in the whole design (see below).
USB-C (mid-mount SMD receptacle, VBUS 5V + D+/D- to ESP32-S3 native USB)
│
▼
Buck regulator (5V→3.3V) ──┬── ESP32-S3FH4R2 (bare QFN56, embedded flash+PSRAM)
│ │SPI (eInk) │SPI (NFC active) │I2C (touch controllers)
│ ▼ ▼ ▼
│ eInk panel ST25R3916B CAP1208 ×2
│ (2.13" flex, NFC front-end (8-ch cap-touch
│ SSD1680, 0.44mm) UFQFPN-32 controller, I2C)
│ │ │
│ [coupling coil] 16 copper pads
│ │ (silkscreened per
│ NTAG 424 DNA sketch: 0-9,
│ (passive tag, confirm/cancel/
│ RF-only, own arrows)
│ perimeter loop
│ antenna — reads
│ from BOTH the
│ internal coupling
│ coil AND external
│ POS/phone readers)
│
└── 3.3V rail also feeds ST25R3916B, CAP1208s, eInk
ESP32-S3's native BLE stays available (matches lnurl-vault's transport) alongside its WiFi station role — both ride the same radio silicon, no extra chip.
| Function | Part | Package | Why |
|---|---|---|---|
| MCU | ESP32-S3FH4R2 (bare die, not a WROOM module) | QFN56, 7×7mm, ~0.9mm thick | Embedded 4MB flash + 2MB PSRAM in-package avoids a separate flash chip. A WROOM-1 module is 3.1mm tall — over 2mm thicker than the bare chip for the same silicon. Directly reuses lnurl-vault's S3 firmware base (native USB-CDC, BLE, HW RNG). |
| Display | Good Display GDEM0213I61-class 2.13" flexible eInk, 212×104, SSD1680 driver, SPI, FPC tail | 0.44mm panel | Thinnest sourceable eInk in the size range that fits the sketch's left-third display zone on an 85.6×54mm card. SSD1680 is ESP-IDF-friendly (same driver family as many Waveshare modules). |
| Active NFC (reader/writer + tag-provisioning) | ST25R3916B | UFQFPN-32, ~0.5mm | Full ISO14443A/B reader+writer, SPI, strong antenna-tuning documentation from ST (needed given the dual-antenna problem below). Chosen over PN532: bare-die option (PN532 is normally sold as a thicker LQFP44 module part), better RF documentation for a from-scratch antenna. |
| Passive tap tag | NXP NTAG 424 DNA (NT4H2421Gx) | Small WLCSP/SOT die option | Contactless-only, no I2C (see correction above). SUN/CMAC dynamic authentication — the exact chip Boltcard uses for LNURL-withdraw tap-to-pay, so LNURLcash/LUD-25 payload semantics carry over directly. |
| Touch input | Microchip CAP1208 ×2 (8-channel cap-touch controller, I2C) | TSSOP/QFN, thin | Resolves a real mismatch: ESP32-S3's native touch peripheral has ~14 channels, not the 16 the sketch's keypad needs. Two I2C controllers give exactly 16 channels, free up GPIO for the two NFC buses + eInk SPI, and hand firmware a clean interrupt+I2C-read contract instead of raw touch-ADC calibration. |
| Power | Small synchronous buck, 5V→3.3V (e.g. TI TLV62569 or similar, SOT-23, <1mm) | SOT-23 | No battery means no charge/fuel-gauge circuitry at all — this is a strict simplification vs. a typical wallet. |
| USB-C | Mid-mount SMD 24-pin receptacle (e.g. GCT USB4105/4110-class or Hirose CX70-class) | ~0.5mm above PCB centerline vs. ~1.6-3.2mm for top-mount | This is the single biggest thickness lever available. A standard top-mount USB-C jack would by itself set the card's minimum thickness above any realistic "credit card" target. |
No battery simplifies this to "must never draw more than USB-C VBUS can supply, with margin for a basic (non-PD) 5V/500mA-900mA host port": ESP32-S3 WiFi TX peaks (~300-400mA), ST25R3916B field generation for active reading (~100mA+ when driving), eInk (negligible except brief refresh current spikes), CAP1208×2 (µA-range, always-on). Budget for a full current-limited 900mA rail and never assume a PD-negotiated 1.5A+ source, since the card should work off cheap USB-A→C cables and basic hub ports.
| Layer | Thickness |
|---|---|
| PCB substrate (thin core, 4-layer likely needed for NFC ground clearance under the antenna area) | ~0.6-1.0mm |
| Bare ESP32-S3 QFN | ~0.9mm |
| ST25R3916B / CAP1208 dies | ~0.5mm each, doesn't stack with MCU (different placement) |
| eInk panel + adhesive | ~0.6-1.0mm |
| Mid-mount USB-C connector | ~0.5-1.0mm above PCB centerline (dominant single item) |
| Solder mask, silkscreen, coverlay over touch pads | ~0.1-0.2mm |
Realistic total: roughly 2.5-3.5mm, not the 0.76mm of an ISO/IEC 7810 card. Card length and width (85.60 × 53.98mm, ID-1) are easy to hit exactly; thickness in that range is honest "as thin as we can reasonably make an eInk+ESP32+dual-NFC+USB-C board," not "literal credit-card thin." A pogo-pin/dock charging approach (dropping the USB-C receptacle for edge contacts + a separate dock) would get closer to true card thickness, but wasn't made the default since the sketch explicitly lists USB-C as an on-card connector — worth a follow-up if thickness ends up mattering more than a standard port.
The passive NTAG424 DNA needs a well-tuned perimeter loop antenna for good read range against external POS/phone readers. The active ST25R3916B needs its own antenna to (a) provision that same tag over RF from a few mm away, and (b) read/write external tags when something else is tapped against the card. Two coils sharing one small PCB will couple to and detune each other unless carefully separated/tuned. This is the one piece of this design that genuinely needs either lab measurement (network analyzer, ST's NFC tuning app notes/tools) or a specialist's review — nobody should hand-wave exact turn counts or trace widths without that.
A real KiCad 10 install (kicad-cli, no symbol/footprint libraries) became available partway
through building this, which upgraded the PCB from a hand-authored guess to a generated,
DRC-checked board — see hardware/README.md for the full account, including two real bugs the
tooling caught (a ; comment that doesn't exist in KiCad's file grammar, and a footprint-position
API-ordering mistake that collapsed every part toward the origin and produced ~1500 false DRC
violations before being found and fixed).
hardware/lnurl-card.kicad_pcbis generated byhardware/build_pcb.py(KiCad'spcbnewPython API, since no footprint libraries exist here to place symbols from) and currently passeskicad-cli pcb drcwith only 12 violations, all of them the same expected, by-design finding: the USB-C connector's pads at the board edge, correct for an edge-launched mid-mount part. Real footprints, real net assignments perhardware/NETLIST.md, a two-sided layout (front: display connector, touch grid, USB-C; back: every IC — the front alone can't physically fit both the keypad and six ICs on an 85.6×54mm card, confirmed by the ~1500-violation first attempt).- Routing is intentionally left undone — including the two NFC antenna loops — needing real RF measurement this environment has no way to perform. 13 nets are unrouted/ratsnest, honestly, not hidden behind a fake example trace (one existed briefly during development, was wrong, and was removed rather than fixed cosmetically).
hardware/lnurl-card.kicad_schis still title-block-only — no scripting API covers schematic capture the waypcbnewcovers the board, so placing real wired-up symbols blind carried the same risk the PCB had beforebuild_pcb.pyexisted. It ERCs clean, but trivially (nothing in it yet).- Before ordering anything: get symbol/footprint libraries installed, do real schematic capture,
replace the three
_APPROX-flagged footprints (USB-C, NTAG424 DNA, eInk FPC) with real vendor footprints, route everything (antennas need bench measurement or a specialist), re-run DRC/ERC, get a DFM review from whichever fab runs the PCBA.
Extends lnurl-vault's existing module boundaries; the paired-companion protocol
(src/proto/dispatcher.c and its wire commands) stays a strict superset — never modified,
only additively extended — so it remains compatible with lnurl-wallet's existing device.ts
client. Standalone WiFi/LNURL flows are a second, independent local-interaction path that drives
vault_* directly under vault_lock, mirroring how lnurl-vault's own local browse/unveil gesture
already bypasses the transport layer today.
vault/,ota/— unchanged, vendored as-is (portable, no ESP-IDF dependency).proto/—dispatcher.c's command set is additive only (new optionalcapabilitiesfields:wifi,standalone,nfc_passive,nfc_active,touch_pads,battery: false; new gated commandswifi_provision,nfc_rewritefollowing the same physical-confirm pattern asexport_secret).button_fsm.c→ newtouch_fsm.c(portable, same shape).screen_sleep.c→ neweink_refresh.c(different problem: ghosting/refresh cadence, not IPS burn-in).approval.c/approval.h— reused verbatim: it already takes two booleans (approve_pressed/cancel_pressed), not raw GPIO reads, so the existing 2-second-hold / debounce / tie-break logic carries over to touch pads with zero change to that file.net/— new:wifi_prov.c,http_client.c(esp_http_client/esp_tls),lnurl_client.c(portable core — this is wherelnurl-wallet'sdeviceOrchestration.tslogic gets ported to C, executed on-device instead of in a browser, per the flowdocs/PROTOCOL.mdalready documents).nfc/— new:nfc_tag.c(NTAG424-class passive tag, written over RF via the active front-end, not I2C),nfc_reader.c(SPI, ST25R3916-class active reader/writer),boltcard.c(portable SUN/CMAC/AES key-diversification logic, host-testable likeota/ota_sign.c).storage/—nvs_storage.creused; new namespaces for WiFi credentials and NFC key material, each getting its own explicit at-rest-trust decision inSECURITY.mdrather than silently inheriting lnurl-vault's "NVS encryption off, physical possession is the model" call — the blast radius differs (a cloned NFC key is indefinite; a leaked note secret is one bearer note).ui/—display.h's state/content model is panel-agnostic and reused; neweink_display.cbackend with a dirty-rect/partial-refresh scheduler (must never fire a full-refresh flash mid confirm-hold — same "don't look broken during a security gesture" bar lnurl-vault's own progress-bar UI was built to satisfy).buttons.c→touch.c(thin adapter overtouch_fsm.c).ui_task.c→keypad_ui_task.c.power/— new, small: mostly documents that the passive NFC tag is the only thing that ever operates with the MCU unpowered; no battery means no graceful-shutdown logic to write.
- ESP32-S3's native touch peripheral has ~14 channels, not 16 — resolved by moving to two external CAP1208 I2C controllers rather than assuming native touch stretches to fit.
- eInk partial-refresh scheduling must be excluded from firing during an in-progress confirm hold.
- I2C (passive tag path) and SPI (active reader path) don't contend on the bus, but the two
antennas' RF coupling means firmware likely needs an explicit mutual-exclusion state machine
between
nfc_tag.candnfc_reader.c(e.g., silence the active field while rewriting the tag). - WiFi/TLS (
esp-tls, mbedTLS,esp_http_client) adds real RAM on top of what lnurl-vault already carries (cJSON, BLE, now an eInk framebuffer) — budget for possibly needing PSRAM, and report a measuredpio runRAM/flash number once buildable, same discipline lnurl-vault's README already sets (36.3% RAM / 27.2% flash, not a guess). - Own HTTPS cert validation is new (lnurl-vault never needed it — the browser owned TLS). Use
ESP-IDF's Mozilla
crt_bundleas a fallback, but lean primarily on LUD-25's existing mint-key signature check (confirm'ssigfield, already validated invault.c) as the real trust anchor for a device holding bearer cash. - WiFi provisioning UX: primary path is BLE-delivered credentials over the existing, already-tested companion transport; on-device 16-pad SSID/PSK entry is a fallback only, not the primary flow.
CONFIG_ESP_WIFI_ENABLED=ydirectly reverses lnurl-vault's core "no on-device networking" design principle — documented as prominently here as its absence is documented in lnurl-vault's, not as a silent flip.
Mirrors platformio.ini's structure: [env:lnurl-card-v1] pinned to the same
espressif32@7.0.1, plus a [env:native] for the portable core extended with boltcard.c and
lnurl_client.c's parsing tests. sdkconfig.defaults deltas: flip CONFIG_ESP_WIFI_ENABLED to
y, add mbedTLS/esp-tls tuning + CONFIG_MBEDTLS_CERTIFICATE_BUNDLE, keep NimBLE/BT enabled
unchanged, both I2C and SPI ride the existing driver component. Vendor the eInk and
ST25R3916B drivers via a pinned-commit + SHA-256-checked script, following the exact precedent
tools/vendor_qrcode.sh already set in lnurl-vault for a library that doesn't resolve cleanly
through PlatformIO's LDF under framework=espidf. Enlarge partitions.csv for WiFi credential
storage and the bigger TLS-carrying app partition; keep the existing OTA dual-slot shape.
lnurl-card/
hardware/ KiCad project (schematic sheets + board outline, see above)
src/ firmware, mirroring lnurl-vault's src/ layout as described above
test/native/ test_touch_fsm.c, test_boltcard.c, test_lnurl_client.c
docs/ PROTOCOL.md (paired-companion, unchanged, vs. new standalone-mode section,
vs. new NFC/Boltcard section), HARDWARE-TEST-CHECKLIST.md (mirrors lnurl-vault's)
SECURITY.md new at-rest-trust decisions for WiFi creds + NFC keys, written out explicitly
platformio.ini, sdkconfig.defaults*, partitions.csv, CMakeLists.txt
- Firmware:
pio run -e nativefor the portable-core unit tests (touch FSM, Boltcard CMAC/SUN known-answer vectors, LNURL/bech32 parsing edge cases), thenpio run -e lnurl-card-v1for a real build once hardware exists, reporting actual RAM/flash usage the way lnurl-vault's README does. - Hardware: open
hardware/lnurl-card.kicad_proin a real KiCad install, run ERC then DRC, finish routing (especially both NFC antennas — ideally bench-tuned against real ST25R3916B eval hardware before committing trace geometry), get a fab DFM review, before ordering boards or assembly. - A
docs/HARDWARE-TEST-CHECKLIST.mdmirroring lnurl-vault's should cover: WiFi join + TLS handshake to a real LNURL mint, NFC tag write-then-external-tap-read round trip, NFC active-reader read of a known tag, all 16 touch pads individually, eInk full vs. partial refresh visually, USB-C enumeration, BLE companion-mode parity test againstlnurl-wallet's existingdevice.test.ts.