LNURLvault — an ESP32-S3 hardware vault for LNURLcash bearer notes — the
offline "banknote" scheme lnurl-wallet and
lnurl-mint implement on top of plain LUD-03
withdrawRequest. The device generates note secrets from a hardware RNG,
discloses only their SHA-256 hash until a mint confirms a rotate/split/merge
succeeded (or an authenticated device-bound mint receipt settles), stores
notes through their pending → confirmed → spent
lifecycle, and gates every plaintext-secret export behind a physical
button press. It talks to a paired browser session over WebSerial (native
USB-CDC) or BLE — see docs/PROTOCOL.md for the wire
protocol.
It also works fully offline, with nothing paired at all: tap either button
to browse CONFIRMED notes, hold both together for ~200ms to unveil the
selected note as an on-screen QR code — the offline
banknote handoff LUD-25 itself describes. See
docs/PROTOCOL.md's "On-device note browsing" section.
Left alone for a minute the screen goes dark — blanked, not just unlit, so the panel does not spend its life wearing a ghost of the resting card. A press of either button brings it back and does nothing else with that press. Nothing else sleeps: a paired host keeps working, and a confirmation arriving while the screen is dark lights it up and asks.
Browser-side integration lives in
lnurl-wallet, not here: src/device.ts
(the client for the protocol below), deviceQueue.ts, deviceOrchestration.ts,
DeviceContext.tsx and pages/Vault.tsx, with device.test.ts and
deviceOrchestration.test.ts alongside them. This repo is the firmware and
its wire protocol; that one consumes it.
This firmware has been built successfully end to end — bootloader,
partition table, and firmware.bin all produced by a real
pio run -e t-display-s3 against ESP-IDF 6.0.1 (GCC 15.2.0,
xtensa-esp-elf toolchain), 36.3% RAM / 27.2% flash used. That build also
found and fixed several real, non-obvious bugs along the way, worth
knowing about even though they're now fixed:
-
A real flashed board reported backlight-on/black-screen/no-serial-output- at-all, which led to catching the most significant bug of the bunch: this board's display is an 8-bit parallel (Intel 8080 / "i80") bus, not SPI.
display.cused to call the SPI panel-IO driver against a MOSI/SCLK pair that was never actually connected to the display — no such signals exist on this board at all — confirmed by diffing against LilyGo's ownpin_config.hand ESP-IDF's bundled i80 LCD example. Sincedisplay_init()runs first inapp_main(), before BLE/storage/our own USB-CDC, a bus that hangs or misbehaves talking to the wrong pins fully explains why nothing ran — not just the screen. Fixed by rewritingdisplay.cagainstesp_lcd_io_i80.hand correctingboard_pins.h(every other pin — CS/DC/RST/BL/POWER_ON/both buttons — already matched LilyGo's config exactly; only the display bus type was wrong). A reflash after that got the bus genuinely talking to the panel (a full-white screen with color glitches, not black) but surfaced two more real, confirmed issues: ESP-IDF's generic ST7789 init only sends SLPOUT/MADCTL/COLMOD/RAMCTRL, and this exact panel needs additional gamma/power/porch tuning beyond that (command bytes transcribed verbatim from TFT_eSPI's own T-Display-S3-specific ST7789 init sequence, not invented — seedisplay.c'ssend_lilygo_t_display_s3_tuning()); and ESP-IDF's ST7789 driver defaults to big-endian pixel data while this file writes native little-endianuint16_tcolors — confirmed by readingesp_lcd_panel_st7789.cdirectly, and independently by ESP-IDF's own bundled i80/LVGL example needing to manually byte-swap for the exact same reason. A third reflash with both of those fixes still glitched, tracing to a third issue:fill_screen()wrote pixel data into a plainstatic uint16_t[], not a buffer allocated viaesp_lcd_i80_alloc_draw_buffer()— whichesp_lcd_io_i80.h's own doc comment says exists specifically to "handle the alignment required by DMA burst, cache line size, etc.", and which ESP-IDF's own bundled i80 example uses for exactly that reason instead of a plain array. A misaligned buffer feeding DMA bursts is a plausible cause of "the bus is provably talking to the panel now, but the image is corrupted" on its own — independent of the command-sequence and endian fixes above, not a replacement for them. A fourth reflash, with all three of the above fixed, was reported as still just a white screen with color glitches — tracing to a fourth, independent issue: this panel is a 170x320 physical glass on an ST7789 controller that natively addresses a 240x320 RAM window, anddisplay_init()never calledesp_lcd_panel_swap_xy(),esp_lcd_panel_mirror(), oresp_lcd_panel_set_gap()— it drewLCD_WIDTHxLCD_HEIGHT(320x170, landscape) rows straight into the controller's default unrotated, zero-gap portrait addressing. Every row write went out at the wrong width against the wrong axis and walked into adjacent controller RAM — independently plausible as a cause of exactly this symptom, and consistent with the bus/tuning/endian/DMA-alignment fixes above all being individually correct yet the screen still wrong. Fixed withesp_lcd_panel_swap_xy(panel, true),esp_lcd_panel_mirror(panel, true, false), andesp_lcd_panel_set_gap(panel, 0, 35)(display.c, right afteresp_lcd_panel_init()) — values confirmed, not guessed, against russhughes/s3lcd'sROTATIONS_170x320table (src/s3lcd.c), an ESP_LCD-based driver written specifically for this panel size, whose{320, 170, 0, 35, true, true, false}entry (width, height, x_gap, y_gap, swap_xy, mirror_x, mirror_y) is the landscape rotation matching this file'sLCD_WIDTH/LCD_HEIGHT. Not yet confirmed against real hardware — if the image renders upside down or mirrored instead of glitched, that table's other 320x170 entry (mirror_x/mirror_yswapped) is the first thing to try; seedisplay.c's comment at theesp_lcd_panel_swap_xy()call. -
Reading
vault_lock.h's own header comment against whatmain.cactually wired up turned up a real, hardware-independent bug: that comment states critical sections holdingvault_lockare "never something like ... the 30s remote-confirm wait," andserial_cdc.c's own header separately states nothing may block insidehandle_rx()since TinyUSB invokes it nested insidetud_task()— butmain.c'sconfirm_export_on_device()(wired todispatcher.c'sexport_secrethandler) called straight intoui_task_request_remote_confirm()'s up-to-30s wait while still holdingvault_lock, because both transports (serial_cdc.c,ble_gatt.c) wrap their entiredispatcher_handle()call invault_lock_acquire()/release(). A client that sentexport_secretand never tapped a button stalled the whole transport task — for serial, all of TinyUSB, not just this one response — and the vault mutex, for up to 30 seconds. Fixed inmain.c:confirm_export_on_device()now releasesvault_lockbefore the wait and reacquires it after, which is safe becausevault_export_secret()(called right after this returns, still under the reacquired lock) independently re-checks the note is stillCONFIRMED— the same re-validate-after-reacquire patternui_task.c's ownunveil()already relies on for the equivalent local-browsing race.ota_begin's confirm wait (ota_approve_on_device()) has the identical problem and is deliberately left unfixed:dispatcher.c's OTA session state (g_ota) has no locking of its own —vault_lockis today the only thing serializingdispatcher_handle()calls across transports at all, so releasing it there would trade this known, bounded issue for an unbounded, unstudied race in an already real-hardware-unverified path. Seemain.c's comment onota_approve_on_device()for what a real fix needs (g_otagetting its own lock, at minimum). -
A comment in
ble_gatt.ccontaining*/mid-sentence closed early, corrupting everything parsed after it into garbage compile errors. -
BLE_UUID128_INIT(...)was assigned directly to a.uuidfield, which wants aconst ble_uuid_t *pointer, not the macro's brace-initializer value — fixed by giving each UUID its own named variable and taking&var.u. -
ble_gatt_start()called aesp_nimble_hci_and_controller_init()that doesn't exist in this ESP-IDF version (nor, it turns out, anywhere in the framework at all) —nimble_port_init()alone already brings up the controller internally. -
The vendored QR library's C-mode
bool/true/falseshim breaks under this toolchain's default-std=gnu23(where they're language keywords, not<stdbool.h>macros) in a way no#undef/re-#includetrick on our side could work around — fixed by patching the shim out of the vendored header at vendor time instead (seeqr_display.c's header comment and "Build & flash" below). -
sdkconfig.defaultsenabled NVS encryption without pinning a key- protection scheme, so ESP-IDF defaulted to the HMAC-peripheral scheme — whilenvs_storage.cseparately hand-implemented the other (Flash- Encryption-partition-based) scheme by calling an older API directly. The two were fighting each other. Fixed by simplifyingnvs_storage.cto call plainnvs_flash_init()(which — per its own doc comment — already handles whichever scheme Kconfig selects internally) and pinningCONFIG_NVS_SEC_HMAC_EFUSE_KEY_ID; the now-unusednvs_keyspartition was removed frompartitions.csv, and there is no such partition in this repo today. (NVS encryption has since been turned off entirely — the HMAC scheme burns an eFuse on first boot, irreversibly, as a silent side effect of first boot. Note storage is therefore not encrypted at rest: a physical flash dump recovers every secret. Physical possession is the protection model. See the security posture section below, andsdkconfig.defaults, which spells out the whole call. Thenvs_storage.csimplification and the partition removal both still stand.) -
Two mechanisms that would have auto-fetched the QR library instead of vendoring it by hand were tried and empirically confirmed not to work for this framework/library combination — see
platformio.ini's comment. -
A real flashed board hung permanently — no response, ever, to any serial command whose JSON body contained a
"cmd"key, regardless of its value's type or validity — the momentsrc/proto/json.c's hand-rolledjson_find_raw()reached its key-match branch. Isolated viatest/hardware/test_serial.pyagainst real hardware (bisected:{}and{"foo":"bar"}always responded fine;{"cmd":123}and{"cmd":"get_info"}never did), not reproducible in native-Ogbuilds, and the root cause was never pinned down despite careful manual tracing. Fixed by replacing the entire hand-rolled reader/writer with the cJSON library (json.cis now a thin adapter preservingjson.h's exact public API, sodispatcher.cneeded no changes) — the firmware pullsespressif/cjsonvia the ESP-IDF Component Registry (src/idf_component.yml), native tests link the systemlibcjson(pacman -S cjson/apt-get install libcjson-dev). Confirmed fixed against real hardware across severaltest/hardware/test_serial.pyruns: the permanent hang never recurs — every command that used to hang forever ({"cmd":123},{"cmd":"get_info"}, etc.) now gets a response every time. A separate, milder issue remains: individual responses occasionally arrive late or (rarely) truncated — 7-13 of 14 checks pass per run, never the same 1-2 that fail, and it gets worse across repeated runs against the same boot without a power cycle in between (confirmed: 10/12 then 7/12 back to back on the same boot).Two concrete theories were tested against real hardware and both ruled out, not just suspected:
- Heap leak/fragmentation from cJSON's per-command allocations. Added a
free_heap_bytesfield toget_info(seedispatcher.h'sfree_heap_fn, wired toesp_get_free_heap_size()inmain.c) and sampled it across 30 consecutiveget_infocalls on one boot: rock steady at exactly 200692 bytes every single time, success or failure. No leak. - BLE competing with TinyUSB for CPU1. TinyUSB's task is pinned to
CPU1 (
CONFIG_TINYUSB_TASK_AFFINITY_CPU1), so a same-core task could plausibly starve it. But both NimBLE's controller and host tasks are pinned to CPU0 (CONFIG_BT_CTRL_PINNED_TO_CORE_0/CONFIG_BT_NIMBLE_PINNED_TO_CORE_0, confirmed insdkconfig.t-display-s3) — can't be the cause.ui_taskis unpinned and could land on CPU1 at the same priority as TinyUSB, but its per-wake work (buttons_poll()— twogpio_get_level()reads and a pure state-machine call) is too cheap to plausibly cause multi-second stalls on its own.
serial_cdc.c's write path was rewritten once real bugs were found in it (confirmed by readingmanaged_components/espressif__esp_tinyusb/tusb_cdc_acm.cdirectly, not guessed):tinyusb_cdcacm_write_queue()returns the number of bytes it actually staged, silently clamped to whatever's free in the 512-byte TX ring buffer (CONFIG_TINYUSB_CDC_TX_BUFSIZE) — the original code ignored that return value entirely, so a response that didn't fully fit got silently truncated or, if the buffer was already full from a still-draining prior response, dropped outright. The fix moved the actual write offhandle_rx()(which runs nested inside TinyUSB's own task, where a blocking flush self-deadlocks — tried and confirmed worse on hardware) onto a dedicatedserial_tx_taskthat checkswrite_queue()'s return value and retries with a bounded, non-blocking loop. Seeserial_cdc.c's header comment for the full mechanism and the reasoning behind each constraint on that loop.Getting that rewrite right took three more rounds of real hardware testing, each catching a genuine regression the previous round introduced: an oversized (~4.1KB) response struct declared as a stack local overflowed
serial_tx_task's stack outright and reset the device (fixed: madestatic); a blocking flush called on every retry iteration stacked with this device's own separate ~2s+ baseline latency (seetest_serial.py's module docstring) until responses arrived outside the test client's read window and got misattributed to the wrong command (fixed: non-blocking flush, only wait when a write genuinely staged zero bytes); and an unbounded retry loop permanently wedged the tx task — and every response queued behind it — the one timewrite_queue()stopped making progress (fixed: a bounded give-up,TX_GIVE_UP_USinserial_cdc.c, generous enough to outlast the ~2s+ baseline latency without waiting forever).Current state, confirmed on real hardware across several runs: no more device resets, no more permanent lockups — every command always gets a chance, and a stuck transfer recovers on its own within the cap instead of wedging the transport. What's not fixed, and was never claimed to be: individual responses still occasionally arrive late, truncated, or (rarely) torn/misattributed to the wrong command — the same class of failure this section opened with, just no longer able to cascade into a full lockup. It's genuinely intermittent (a different command fails each run) and gets worse over a longer-running boot, consistent with the original description. The two ruled-out theories above still stand.
A source-confirmed framing bug in that failure path is now fixed, but is not yet bench-run. When the bounded TX loop abandoned a reply after some bytes had already been queued, it left no newline behind. The next intact JSON reply was appended directly to that prefix, turning one lost reply into the console's later "unparseable" line as well. The portable
line_tx.cstate machine now remembers a partial abandonment and sends a standalone newline before any later reply; if the delimiter itself cannot be queued, it drops the new reply whole rather than contaminating it. Native tests reproduce the partial-write/stall/recovery sequence byte for byte. This restores framing after a loss; it does not explain or remove the underlying TinyUSB stall, and requires an S3 bench run before being called a hardware fix.The first field report against that firmware measured the loss, and it is below
serial_cdc.centirely. Two damagedget_inforeplies from an external tester's S3 over WebSerial, reconstructed against the 406-byte reply v0.0.11 sends, were missing bytes 256..319 in one case and 0..255 in the other, with everything either side intact. Both are 64-byte-aligned: one full-speed USB bulk packet vanished from the middle of a reply and the reply carried on, anddropsread zero throughout, which is consistent rather than contradictory: nothing in this firmware can see a packet the USB stack lost afterwrite_queue()accepted the bytes. The S3 builds had been getting esp_tinyusb's default Buffer DMA mode for TinyUSB's DWC2 driver since that default appeared (esp_tinyusb 1.5;sdkconfig.defaults. esp32s3never said either way and the generated sdkconfig is not tracked). In that mode TinyUSB's CDC writer pops one 64-byte packet from the 512-byte TX ring into an endpoint buffer before the controller has accepted the transfer, so a transfer refused at that moment loses exactly that packet, silently, and the next one goes out as normal — the shape of the report. Slave/IRQ mode reads the ring straight into the hardware FIFO as one multi-packet transfer and has no such path; it is also the older path, the one every ESP32-S2/S3 TinyUSB build used before the option existed.CONFIG_TINYUSB_MODE_SLAVE=ynow selects it. What this does not do is name the trigger: read against the resolved TinyUSB 0.21.0 source, the claim/busy protocol around that transfer looks sound in steady state, and the one refusal path found (an endpoint closed by a bus reset) should cost a whole reply rather than one packet. So this is the strongest lead, one line to revert, and not a proven fix. Upstream TinyUSB has threeusbdfixes from August 2026 in no release yet (a completion event dropped by a full queue wedging its endpoint for good; a refused transfer treated as fatal; bus reset split into two edges), any of which could be the next thing to pull. The second sample, a reply missing its first four packets, is what a port closed and reopened mid-reply looks like, which a wallet that times out and reconnects would do on its own;get_infonow carries ausbobject (configured,unconfigured,suspends,resumes,tx_xfers) so the next report can say whether the host re-enumerated the device or the app let go of it. Not yet bench-run: there is no S3 on the bench, and the reporter is the test.Two more things, learnt from the S3 firmwares that are stable. A survey of Blockstream Jade, Arduino-ESP32, MicroPython and CircuitPython found the DWC2 mode split two against two (Jade and CircuitPython on DMA, Arduino and MicroPython on slave), so the mode switch above is a fair experiment rather than the answer, but three of the four pin or vendor their exact TinyUSB, where this repo's
^1.4took whatever the registry had newest on the day.src/idf_component.ymlnow pins both components to the versions the S3 build was checked against; TinyUSB 0.18.0, the exact component hash Jade ships, also builds clean on this toolchain and is the fallback if 0.21 in slave mode is not enough, at the cost of lacking the OUT-endpoint zero-length-packet fix MicroPython had to cherry-pick onto it. And MicroPython's CDC driver clears its TX FIFO when the host drops DTR, because bytes queued for a client that has closed the port come out as the first thing the next client reads: a stale, torn tail, which is the second field sample above to the byte.serial_cdc.cnow does the same on DTR down and up, abandons a reply still being written under a closed port, and counts opens and closes inusb.port_opens/usb.port_closes. The wallet closes the port on every timeout, so those two counters are what will say whether its disconnects are its own. Also not yet bench-run.The device console now also stops after a client-side timeout until that late line arrives or the owner reconnects. Previously it removed the timed out request from its FIFO immediately, so a delayed reply could be handed to the next command as if it belonged there; an unmatched late line was hidden altogether. The console preserves both late and unmatched raw lines now and renders
get_info'sdropsand reset diagnostics directly.A live hardware session pointed at the read side, not the write side: responses, once they start, arrive fast — the delay is in the device seeming slow to notice a command in the first place. Reading
serial_cdc.cagainst that report turned up a real, confirmed-by-code match:handle_rx()calleddispatcher_handle()(JSON parse/build, SHA-256, vault iteration — genuine work, not a fixed small cost) directly, inline, still nested insidetud_task()— the exact same "don't block the one task servicing the USB peripheral" rule points 1-3 above already established and fixed for the write path, just never applied to the read/process side. Whiletud_task()is captive runningdispatcher_handle()for one message, it can't service the USB peripheral for anything else, including noticing the next incoming message — which is exactly the reported symptom. Fixed the same way the write path was:handle_rx()now only assembles a complete line and hands it to a newserial_rx_taskover a queue;dispatcher_handle()(and thevault_lockaround it) moved there, offtud_task()entirely. Compiles and passes the native suite; not yet confirmed on real hardware — this is the strongest untested lead so far, not a confirmed fix, and the underlying USB-OTG/DWC2-level suspicion from before isn't ruled out just because this one is plausible. If it's re-tested and the flakiness persists, live instrumentation around the actual write/flush call (e.g. loggingtud_cdc_n_write_available()at the moment a stall is detected) is still the next step, not more static reasoning from source.Added a
resetcommand (dispatcher.c/dispatcher.h,main.c,docs/PROTOCOL.md) as a mitigation, not a fix, for the pattern documented above: it's a mitigation because the degradation is observed to get worse the longer a boot runs and to clear after a power cycle, so giving a remote client a way to force that power cycle is directly useful even without knowing the root cause. It responds{"ok":true}first, then reboots on a delay (RESET_DELAY_USinmain.c, 10s) rather than immediately, specifically so the response has a real chance to actually leave the TX buffer before the device disappears — an immediateesp_restart()would race that. Available from the device console too. Not yet run against real hardware. - Heap leak/fragmentation from cJSON's per-command allocations. Added a
What has and has not been run on hardware is tracked per area, with
dates and firmware versions, in
docs/HARDWARE-TEST-CHECKLIST.md — that
file, not this paragraph, is the authority, and it marks anything unrun as
NOT YET BENCH-RUN in those words rather than leaving it implied. In
outline, as of 2026-08-17: the serial and BLE command protocols, the note
lifecycle, the export confirm gate's timeout path, display orientation, the
button state machine at rest, QR rendering and scanning, NVS persistence
across resets, and crash reporting have all been exercised on a classic
T-Display. The approval gesture itself, granting a wipe, an actual OTA
transfer, and the ESP32-S3 target's display and native-USB paths have not.
test/hardware/bench.py runs every check that does not need a finger on the
board. A different installed ESP-IDF/PlatformIO version than the pinned one
could still turn up something new; each fix above is documented in the
relevant file's own header comment as a starting point if it does.
src/vault/ and src/proto/ (the note state machine, SHA-256, hex, base64,
JSON reader/writer, command dispatcher, button gesture state machine, and
lnurlw:// URL builder) are pure portable C with no ESP-IDF dependency at
all, and are additionally exercised by test/native/ — 933/933
assertions pass — independent of the ESP32 build. This is the security-
and protocol-critical logic, including the debounce/tap/chord logic gating
every plaintext-secret disclosure and the OTA signature-verification/
sequencing state machine (see "OTA firmware updates" below).
src/
vault/ note state machine (PENDING/CONFIRMED/SPENT), SHA-256, hex —
portable, no ESP-IDF dependency
proto/ JSON reader/writer, base64 (OTA chunk payloads), the
transport-agnostic command dispatcher, the button gesture
state machine, the lnurlw:// URL builder — all portable, no
ESP-IDF dependency
ota/ OTA signature verification (ota_sign.c, portable, over vendored
Monocypher — monocypher*.c/.h, see "OTA firmware updates"),
plus ota.c's esp_ota_ops.h glue and release_key.c's baked-in
public key (both ESP-IDF only)
storage/ NVS-backed vault_storage_t implementation (ESP-IDF only)
transport/ serial_cdc.c (USB-CDC/WebSerial), ble_gatt.c (NimBLE) — both
take vault_lock.h's mutex around each dispatcher_handle() call
board/ the hardware-abstraction seam: one board_*.c per physical
board brings up the panel (bus, pins, rotation, offsets) and
the buttons, and hands back a plain width x height surface.
Nothing above this layer names a pin or a bus type. Adding a
board is one file plus one line in CMakeLists.txt
ui/ display.c (drawing only), buttons.c (thin adapter over
proto/button_fsm.c), qr_display.c (needs a vendored QR
library, see Build & flash), and ui_task.c — the single task
owning buttons+display for both local note browsing and
remote export_secret/OTA confirm requests (T-Display S3)
vault_lock.c the mutex serializing vault.c access between the transport
task and ui_task (ESP-IDF only, see its header comment)
device_reboot.c shared delayed-reboot helper for `reset` and a
successfully finalized `ota_finish` (ESP-IDF only)
main.c wires RNG, storage, transports, OTA, and UI together
test/native/ unit tests for src/vault + src/proto + src/ota, plain gcc, no
hardware
tools/ota_push.py host-side OTA signing/push CLI — see "OTA firmware
updates" below
docs/PROTOCOL.md full wire protocol + on-device browsing reference
The design principle: the device is the only thing that ever holds a
note's plaintext secret, except at the moment a client deliberately
exports it (to spend, hand over, or display as a note). Every mutation
(rotate/split/merge) is a two-phase commit — the device generates the new
secret and discloses only its hash; nothing is finalized until the client
reports the mint accepted it. See docs/PROTOCOL.md's
"Orchestration" section for exactly how a client sequences this.
The device does no networking of its own — no Wi-Fi driver is even built in
(see sdkconfig.defaults). All mint HTTP calls are the paired browser's
job; the device's only interface to the outside world is the USB-CDC or BLE
session itself. That includes firmware updates: OTA (below) is deliberately
serial-only, over the exact same JSON protocol as everything else — no
network stack was added to get it, and none is needed.
Adapted from forgesworn/heartwood-esp32's
OTA design — investigated after the user pointed at that project as prior
art. Their device also does this over USB serial rather than the network
their firmware otherwise uses for its main function, deliberately: "There is
deliberately no remote OTA implementation. Firmware updates remain USB-only,
release-signed, and physically approved" (their docs/SECURITY-MODEL.md).
That's a clean match for this project's own no-networking design, so the
scheme is ported here close to as-is: release images are ed25519-signed
(the device only ever verifies — the private key never touches it), the
signature is checked twice (ota_begin over the claimed digest, before the
owner is even asked; ota_finish over the digest of what was actually
written to flash — the check that carries the real guarantee), and the
transfer is gated by the same physical confirm/cancel tap export_secret
already uses.
Two adaptations from heartwood's version, both because this project's wire protocol and toolchain are different from theirs (Rust/esp-idf-hal; a binary-framed protocol from the start) rather than because anything here is better or worse:
- Base64-over-JSON, not binary framing. heartwood's whole serial
protocol is binary-framed; this project's is newline-delimited JSON
throughout, so OTA chunks (
ota_chunk'sdatafield,src/proto/base64.c) stay on that same wire format instead of introducing a second, binary sub-protocol multiplexed onto the same connection — simpler to reason about, at the cost of ~33% more bytes on the wire per chunk. Given this device's own documented serial latency (see Status below), that tradeoff favored simplicity. - Monocypher instead of a Rust crate. This is
C, not Rust —
ed25519-compactwasn't an option.davylandman/MonocypherIS a real PlatformIO registry package, and — unlikericmoo/QRCode(see "Build & flash", andplatformio.ini's comment for exactly why that one specifically gets filtered out) — it actually resolves vialib_depshere: it ships a properlibrary.json, confirmed with a realpio run(LDF: ... Found 1 compatible libraries). Not used anyway: it's pinned to 2.0.6 from 2019, four major versions behind the 4.0.3 vendored here, and pulling both would mean two physically different copies of the same crypto code in one firmware image, only avoided colliding by accident of which one the linker resolves symbols from first — not something to depend on. Vendored whole instead, the same wayqrcode.calready is (see "Build & flash"):src/ota/monocypher.c/.hand the optionalmonocypher-ed25519.c/.hmodule, unmodified from upstream 4.0.3.src/ota/ota_sign.cwraps just the one call the device needs (crypto_ed25519_check) behind the exact same domain-separated message scheme heartwood'scommon/src/ota_sign.rsuses ("lnurlvault-ota-v1" || 0x00 || sha256(image), no board id — this project targets exactly one board today). Cross-checked, not just unit-tested: an image signed bytools/ota_push.py(Python'scryptographylibrary) was verified successfully byota_verify_signature()(Monocypher/C) directly, and a tampered digest against that same signature was correctly rejected — confirms the two implementations actually agree on the wire format, not just that each one is internally consistent.
What's tested and what isn't. The portable parts — parsing, base64,
signature verification, the ota_begin/ota_chunk/ota_finish sequencing
state machine (bad signature, declined/timed-out approval, out-of-order
chunks, early finish, a corrupted transfer caught at the finish-time
re-verify, recovery via a fresh ota_begin after an abort) — are exercised
by test/native/ against a fake in-memory "flash", and the whole thing
(including the new app_update-based ota.c glue and the new
otadata/ota_0/ota_1 partition table, see partitions.csv) compiles
and links in a real pio run -e t-display-s3 build. None of it has run
against a real device — no actual OTA transfer over USB has happened.
The physical-approval gate (ui_task_request_ota_confirm, reusing
ui_task.c's existing request queue) is architecturally identical to
export_secret's. That one's timeout path is confirmed on hardware — an
unanswered request returns {"ok":false,"error":"timeout"} after ~31s over
both transports, with the BLE link intact throughout — but nobody has yet
pressed a button to approve an OTA image. src/ota/release_key.c's OTA_RELEASE_PUBKEY now holds a
real generated key (python3 tools/ota_push.py keygen), not the all-zero
placeholder — ota_begin will accept images signed with the matching seed
instead of failing closed against everything. That seed needs to actually
be set as this repo's OTA_SIGNING_SEED CI secret for release.yml's
signing step to work (see just below) — not confirmed from here.
CI signing is wired: release.yml now signs every tagged release's
firmware.bin automatically via the OTA_SIGNING_SEED repository secret
(tools/ota_push.py sign, invoked from a new "Sign firmware for OTA"
step), publishing firmware.bin.sig as a release asset — same shape as
heartwood's OTA_SIGNING_SEED GitHub secret (see their
docs/ota-signing.md), adapted to this project's Python tooling instead
of their Rust one. The release fails closed if the secret isn't set —
python3 tools/ota_push.py keygen prints the exact gh secret set command
to configure it, and the seed is stored hex-encoded, not as raw bytes:
GitHub Actions secrets are handled as text/env vars, and a raw 32-byte
value can contain bytes that don't survive that round-trip intact; the
workflow decodes the hex back to bytes itself before signing. This part of the workflow has now been exercised on a real tagged
release. v0.0.4 was signed by CI, and its firmware.bin.sig verifies
against the public key compiled into the firmware — checked from the
published artefact rather than from the build that made it, and confirmed on
a real board, which accepts that signature at ota_begin and refuses a
tampered one in 0.2s.
Key rotation is a two-release operation, the same shape as heartwood's
own (docs/ota-signing.md), because a device only trusts the key it was
built with, not whatever the CI secret currently holds: generate the new
keypair, but ship the next release still signed with the old seed —
that release just carries the new public key baked into release_key.c.
Only once devices have updated through that release do you swap
OTA_SIGNING_SEED to the new seed; from the release after that one
onward, new signatures use the new key, which those now-updated devices
can verify. A device that skips the in-between release has to update
through it (or get a fresh USB flash) before it can accept anything signed
with the new key. If the seed is ever compromised, treat OTA as untrusted
until rotation completes — an attacker with the seed still needs the
physical approval tap to push anything, so the blast radius is local, but
rotate promptly regardless.
What OTA signing does and doesn't protect against — stated as plainly
as heartwood states it for their own: it protects the update path only.
It does nothing for the first USB flash (today's pio run -t upload /
the web installer both write an unsigned image to a blank device — trust-
on-first-use) or for an attacker with physical access and a debugger.
Closing that gap needs Secure Boot V2, which — like Flash Encryption,
covered in Security posture below — is a separate, not-yet-taken hardening
step, not something OTA signing substitutes for.
Requires PlatformIO (pip install platformio
if you don't have it), which resolves the ESP-IDF toolchain itself on
first build.
Before the first build, vendor the QR encoder qr_display.c needs —
not included in this repo (see its header comment for why, and for two
auto-fetch mechanisms that were actually tried and confirmed not to work
for this library, not just skipped):
./tools/vendor_qrcode.sh src/uiThis is the same step both workflows run. It fetches
ricmoo/QRCode (MIT) at a pinned
commit, checks both files against pinned SHA-256 hashes, and refuses
to proceed on a mismatch — this library is compiled into a firmware that
holds bearer secrets, and a moved tag or a force-push would otherwise
change what ships with no record of it. It then applies the one patch
qrcode.h needs to build here (see qr_display.c's header comment for
why). Download it by hand and you skip the verification, so use the
script.
pio run -e t-display-s3 -t upload
pio device monitorBoard: LilyGo T-Display S3
(ESP32-S3R8, 170×320 ST7789 LCD on an 8-bit i80 parallel bus, 2 buttons).
Verify src/board/board_t_display_s3.c against your specific board
revision using LilyGo's own pin_config.h before flashing — LilyGo has
shipped more than one board under similar names. See that file's header
comment.
If pio run fails on a specific symbol (an NVS-encryption call, a NimBLE
header, an esp_lcd struct field), that's expected per the Status section
above — the failing file's own top comment says which ESP-IDF version
change is the likely cause and what to check against.
Pushing a tag (git tag v0.1.0 && git push origin v0.1.0) triggers
.github/workflows/release.yml, which:
-
Builds the firmware with PlatformIO for both supported boards (same as "Build & flash" above). Releases used to build only the S3, so anyone holding a classic T-Display got no installer image and no OTA image at all, with nothing saying so.
-
Signs
firmware.binfor OTA (tools/ota_push.py sign, driven by theOTA_SIGNING_SEEDrepository secret) and publishesfirmware.bin.sigalongside it — see "OTA firmware updates" above for the scheme andtools/ota_push.py keygen's own output for how to set that secret in the first place. Fails the whole release closed if the secret isn't set, deliberately, rather than shipping a release nobody's device can ever accept over OTA. -
Merges
bootloader.bin+partitions.bin+ota_data_initial.bin+firmware.bininto onemerged-firmware.binper board viaesptool.py merge_bin, with each board's SPI flash mode/frequency/size and — importantly — its own bootloader offset baked in, all read off that board's ownpio run -t upload -vinvocation rather than guessed:Board Flash Bootloader at ESP32-S3 dio/80m/16MB0x0classic ESP32 dio/40m/16MB0x1000That offset differs by chip and an image built with the wrong one flashes cleanly and never boots. esp-web-tools' own docs are explicit that it can't patch these on the fly when writing multiple separate parts over Web Serial, and that ESP-IDF firmware needs to be pre-merged — skipping this is a real, confirmed way to get "the installer reports success, but the board never actually boots the firmware." (This merged image is for that initial flash only — OTA updates only ever replace the plain
firmware.binapp image, never the bootloader or partition table, so it's signed separately in step 2 rather than as part of this merge.) -
Publishes a GitHub Release for the tag with all of the above (including both
.sigfiles and aSHA256SUMSover everything) plus amanifest.jsoncarrying both boards — esp-web-tools picks the build matching the chip it finds. Each build points at its own merged binary at offset0;partitions.csv's offsets are already baked into the merge, not re-declared in the manifest the way an earlier version did it. -
Regenerates and deploys
webinstaller/to GitHub Pages: a page that lists every published release (fetched viagh release list, no server needed beyond static Pages hosting) and lets a visitor flash any of them directly from Chrome/Edge over Web Serial — no PlatformIO, no command line, just a USB cable. Every release's binaries are copied into the deployed site itself (firmware/<tag>/) rather than linked cross-origin to GitHub's release CDN, which doesn't serve CORS headersfetch()would need — see the workflow's "Fetch every release's firmware assets" step, which then verifies that every file each manifest references actually landed in the site and fails the job otherwise. That check exists because the installer had never once worked:merged-firmware.binwas not among the copied patterns even though it is the only file the manifest names, and the step reported success while producing an emptyfirmware/directory, so every release the page offered returned 404. A release that does not install is worse than no release, because it looks like it worked.A release whose assets cannot satisfy its manifest is dropped from the page rather than failing the job —
v0.0.2predatesmerged-firmware.binand nobody can change that history — andreleases.jsonis filtered to match, so the dropdown never offers a version that 404s on selection.
That last step also runs standalone (workflow_dispatch, no new tag
needed) — useful for updating webinstaller/index.html itself, or after
manually deleting a bad release, without cutting a new firmware version.
One-time manual setup this workflow can't do for you: Settings → Pages
→ Source: "GitHub Actions" — the github.io URL won't serve anything until
that's set; and the OTA_SIGNING_SEED repository secret (Settings →
Secrets and variables → Actions), from python3 tools/ota_push.py keygen
— see "OTA firmware updates" above. The web installer page currently
hardcodes this repo as dni/lnurl-vault in a couple of "view source"
links.
A flashed install erases every note on the device. merge_bin pads the
gaps between parts, so an image written from offset 0 covers the NVS
region with 0xFF — measured, on a board that went from a full vault to
zero notes across a re-install. It is not an update in place, whatever the
erase prompt suggests. Updating a device that already holds notes means a
signed OTA image, which only ever replaces the app partition.
This workflow has run and reported success on real tags. That is not the
same as having worked: v0.0.2 through v0.0.4 all published usable release
artefacts and all deployed an installer page that could not install any of
them. What is verified as of v0.0.4 is the artefact side — checksums
verify, and firmware.bin.sig verifies against the public key compiled into
the firmware, over the domain-separated message ota_signing_message()
builds rather than the bare digest. The device side is verified as far as
ota_begin: a real board accepts that signature and refuses a tampered one
in 0.2s, before the owner is prompted. Nothing has yet completed an approved
transfer. See docs/HARDWARE-TEST-CHECKLIST.md sections 15 and 16.
If .pio/build/t-display-s3/'s actual output filenames ever stop matching
what the "Collect build outputs" step expects (a different PlatformIO/
ESP-IDF version resolved by the runner could still rename them), that
step's find patterns are the first thing to check.
- Secrets come from
esp_fill_random()(the ESP32-S3's hardware TRNG). Espressif documents its full-entropy guarantee as conditional on Wi-Fi or BT having been active at least once —main.cstarts BLE before the first RNG self-test or secret generation to satisfy that. A cheap startup self-test (two 16-byte draws must differ and not be all-zero) guards against a catastrophically stuck RNG; it is not a statistical randomness test suite. - Storage: NVS encryption is off (
CONFIG_NVS_ENCRYPTION=n, seesdkconfig.defaults), so note storage is not encrypted at rest — a physical flash dump recovers every secret. It is off deliberately: on the ESP32-S3 the HMAC key-protection scheme burns an eFuse on first boot, irreversibly, as a silent side effect of powering the device on. Physical possession is the protection model — treat the device like cash in a wallet. Real protection against physical extraction requires provisioning Flash Encryption + Secure Boot V2 — a deliberate, irreversible (eFuse-burning) manual production step, intentionally left to you before this device ever holds value you care about. OTA image signing (below) does not substitute for this: it protects the update path, not a first USB flash or physical/ debugger access — see "OTA firmware updates" above for exactly where that line is. (Making NVS encryption a build-time production option is tracked in the issues.) - No networking on-device. The only attack surface is the paired USB-CDC or BLE session — OTA firmware updates included, deliberately (see "OTA firmware updates" above). All mint calls are the browser's responsibility.
export_secretover serial/BLE — the only remote command that ever discloses a plaintext secret — is gated byui_task.c, requiring a physical tap of one button (confirm) or the other (cancel) within a 30s timeout, using the same debounce/gesture logic (src/proto/button_fsm.c) that also drives on-device browsing — see Verification for how that's tested.- The on-device browse-and-unveil gesture (see
docs/PROTOCOL.md) is a second, independent path to a plaintext secret, gated differently: holding both buttons together for ~200ms is the confirmation (reaching that point already required physical possession of the device), rather than a separate confirm/cancel step. Once shown, a QR code is the bearer secret in the clear — that's the point, it's meant to be scanned and redeemed by whoever it's shown to, exactly like handing over a banknote. vault.chas no locking of its own (it's shared with the native test binary, which is single-threaded) — on firmware,vault_lock.cserializes every call to it between the active transport's task andui_task.c. See that file's header comment for the design and why holding the lock for up to 30s during a pending remote confirm is safe (ui_task isn't trying to touch vault state locally during that same window).
- On-screen note detail is now rendered (
src/ui/display.c'sdisplay_note_detail, over thesrc/ui/font5x7.cbitmap font): while browsing, the screen shows the selected note's amount, label and id plus its 1-based position, and the confirm prompt shows the same for the note being approved — so approving or unveiling a note is a deliberate read, not a miscount. The font is a hand-transcribed 5x7 bitmap; it renders and scans on hardware (seedocs/HARDWARE-TEST-CHECKLIST.md), and moving to LVGL for richer text remains a possible future step. - The rest of the screens now say something too. An outcome is
APPROVED/DECLINED/NO ANSWERin words, naming what it was about, rather than a flat rectangle. Boot says what the device is and which build. At rest it says how many notes it holds and that a tap will show them — deliberately not what they are worth. When a button was already down as the prompt appeared the card saysLET GO FIRST, since a held button answers nothing until it has been seen released (approval.h). - No countdown on the confirm prompt. The window is 30 seconds and
nothing on screen shows it draining. There is no room for it on the 240x135
panel without taking a row from something that matters more, and the
NO ANSWERcard covers the confusion that actually mattered — a prompt that vanished with no explanation. - The QR encoder is a required external dependency, not included in
this repo — see "Build & flash" above. Nothing in
src/ui/qr_display.cwill compile until it's vendored (confirmed both PlatformIOlib_depsand anidf_component.ymlgit dependency don't work around this for this particular library — seeplatformio.ini's comment for what was tried). - OTA exists but is untested on hardware and unusable until a release key is generated — see the "OTA firmware updates" section above for what's built, what's cross-checked, and what still needs a real device.
- Device identity (
identify, issue #69) gives a host something to pin: a per-device ed25519 key, generated on first boot and kept in NVS, answering a challenge the host chooses. It proves the vault answering now holds the same key as last time, which is what trust-on-first-use needs — and nothing about who is holding it. Awipedestroys it, so a wiped vault is deliberately a different vault to anything that pinned it. Not bench-run; seedocs/HARDWARE-TEST-CHECKLIST.mdsection 18. - BLE pairing/bonding is unauthenticated in this v1 (any nearby device can connect and issue commands, though it still can't extract a secret without a physical gesture on the vault itself). Consider BLE bonding/passkey pairing before relying on BLE outside a trusted room.
.github/workflows/ci.yml runs both of the
following automatically on every push to main and every pull request: the
native unit tests, and a PlatformIO build of the firmware (no upload, no
board needed — just "does it compile"). Unlike release.yml, it publishes
nothing; it's a signal, not a release gate.
Native unit tests (portable core, no hardware, no PlatformIO needed;
needs the system cJSON library — pacman -S cjson or
apt-get install libcjson-dev, see test/native/Makefile):
cd test/native && make testThis actually runs in this environment — 933 assertions across SHA-256
known-answer vectors, JSON reader/writer round-trips (including escaping and
the overflow-detection path), the full vault state machine (legal and
illegal state transitions, split/merge parent lineage, the id-collision
retry path, and a simulated-reboot persistence round-trip against a fake
storage backend satisfying the same vault_storage_t interface
src/storage/nvs_storage.c implements for real), the lnurlw:// URL
builder, the button gesture state machine (debounce/bounce filtering,
simple taps, a chord that fires exactly once and doesn't also emit trailing
taps, and staggered chord entry), base64 round-trips (RFC 4648 vectors plus
a 1024-byte binary chunk), the ed25519 OTA signature scheme (a genuine
signature verifies, a tampered digest/wrong key/corrupted signature/
all-zero placeholder key all fail closed), and the full ota_begin/
ota_chunk/ota_finish sequencing state machine against a fake in-memory
"flash" (happy path, bad signature, declined/timed-out approval, a
wrong-offset chunk that doesn't kill the session, no-active-session errors,
an early finish, a corrupted transfer caught at the finish-time re-verify,
and recovery via a fresh ota_begin after an abort), and — since the
screens became testable — what actually reaches the panel: that every line a
confirm card promises is drawn on both real geometries, that nothing is drawn
off the glass or into the progress bar's band, and that no line reaches the
panel edge.
Screen previews (no board, no PlatformIO):
cd test/native && make preview # PNGs in test/native/preview/Renders every screen the firmware can draw — each stage of boot, idle, browse, each confirm verb, the outcome states, and the awkward cases (a seven-figure amount, a label longer than the panel, a sub-sat note) — at both real panel geometries, as PNGs you can open.
They are drawn by src/ui/display.c itself against a framebuffer, not by a
second implementation that would drift from it: test/native/hostgfx/
stands in for the four ESP-IDF pieces that file uses, so what comes out is
what the glass gets, at the same width, height and font. make test uses the
same harness to assert about pixels (test_card_render.c).
This exists because every display fault this project has shipped was a layout fault, none was reachable from the build, and each was found by a person squinting at a board — one of them only from a photograph. It found a live regression on its first run; see section 19 of the hardware checklist.
Hardware verification (needs a flashed board):
Done — flashed to a real T-Display S3 and round-tripped over USB-CDC usingpio run -e t-display-s3 -t upload && pio device monitortest/hardware/test_serial.py(13/14 checks pass; see the Status section above for what that confirmed, including the cJSON migration fix). Vendoring the QR library (see "Build & flash") is still needed for a build that includes the on-device QR/unveil UI.- Exercise the rest of the command set by hand, typing JSON lines directly
into the serial monitor:
new_secret→confirm→list_notes→export_secret(press the confirm button when the display goes amber) →mark_spent→list_notes. - Separately validate BLE: connect with a generic GATT explorer (e.g. nRF
Connect), write a JSON command chunked per
docs/PROTOCOL.md's framing (including a message deliberately larger than one MTU, to exercise reassembly), and confirm the notified response reassembles correctly. - Exercise on-device browsing with at least two
CONFIRMEDnotes: tap to enter browse mode (the card names the note and its position), tap again for the next one, hold both buttons together (~200ms) to unveil — confirm a QR appears, and scan it with a stock phone camera: it should open the wallet with that note claimable (the payload is an https claim link by default — seedocs/PROTOCOL.md's "On-device note browsing", and issue #26 for why it is notlnurlw://any more). Tap once to dismiss and confirm the screen actually clears. - Not yet attempted: a real OTA update end to end.
python3 tools/ota_push.py keygen --out ota-release.seed, paste the printed public key intosrc/ota/release_key.c'sOTA_RELEASE_PUBKEY, reflash once over USB the normal way, thenpython3 tools/ota_push.py push --port /dev/ttyACM0 --image .pio/build/t-display-s3/firmware.bin --seed ota-release.seed— approve on the device when prompted, and confirm it reboots into the new image (get_info'sfw_versionis the easiest thing to check before/after if you bumpLNURLVAULT_FW_VERSIONbetween builds). See "OTA firmware updates" above for what's already cross-checked (Python↔C signature verification) versus what genuinely isn't (a live transfer, the physical approval prompt, and whetheresp_ota_set_boot_partition+ a reboot actually lands on the new image).