feat(machine): Bolt Card (NFC) tap-to-receive on cash-in #84

Merged
padreug merged 1 commit from feat/boltcard-nfc-cashin into dev 2026-08-06 16:33:10 +00:00
Owner

Tap-to-receive for the buy flow — the receive counterpart to #83's cash-out tap-to-pay.

The problem

A Bolt Card only ever emits its lnurlw withdraw voucher (a spend credential) — the wrong direction to deposit into it. So a plain tap can't push sats to the card, and lnurl-auth doesn't help either (NTAG424 does AES SUN, not the secp256k1 signing lnurl-auth needs).

The approach

Use the same tap as an authenticated identity (external_id + the SUN p/c, verified server-side exactly as /scan does) to resolve the card wallet's lnurlp / Lightning Address, then have the ATM pay an invoice for the payout amount over its existing nostr transport. Settlement reuses the tested cash-in completion path (payInvoice → PAYMENT_RECEIVED).

Card issuance is unchanged — same NDEF, same keys, same external_id. Receive is a server-side reading of the same tap; already-issued cards work as-is.

What's in this PR (ATM side — complete, hardware-independent)

  • electron/lnurl-pay.ts (new) — resolveCardInvoice(lnurlw, amountMsat): resolve card → pay target → LUD-16/LUD-06 → returns a BOLT11. Accepts three resolver response shapes (Lightning Address / inline payRequest / lnurlp pointer). scanUrlToResolver() is the single HTTPS-today / Nostr-tomorrow transport seam. Never throws. 13 tests.
  • IPC lnurl:pay-card (main.ts) — runs the HTTPS in the main process to dodge renderer CORS; preload.ts + electron.d.ts expose resolveCardInvoice().
  • stores/atm.ts — handleBoltCardReceive(); the single NFC listener now routes the same physical tap by flow: cash-out displayingInvoice pulls, cash-in displayingQR receives. Status/cleanup extended to the QR screen; simulateBoltCardReceive() for dev.
  • CashInView.vue — NFC status line + dev paste-lnurlw "Tap" input.
  • docs/boltcard-receive-resolver.md — spec for the one custom LNbits endpoint this depends on.

Depends on (LNbits omni-private, not in this repo)

A custom GET /boltcards/api/v1/pay/{external_id}?p=&c= resolver — a /scan sibling that verifies the same SUN and returns the card wallet's Lightning Address. Spec in docs/boltcard-receive-resolver.md. Until it exists, the tap path is testable via the dev input / mocked fetch; the withdraw-QR cash-in path is unaffected.

Notes

  • Double-payout window: the withdraw-QR fallback and the tap are either/or; the withdraw link is uses:1 and the tap gates re-entry + leaves displayingQR on success. Documented as acceptable-for-now in the spec.
  • Future Nostr: a nostr-native boltcard would answer the same external_id + SUN identity over the ATM's existing nostr connection, dropping the clearnet HTTPS call — localized to scanUrlToResolver/the resolve step.

Verification

  • pnpm typecheck (renderer + electron) clean
  • Prettier clean
  • vitest run electron/ — 55 passed (13 new in lnurl-pay.test.ts)

🤖 Generated with Claude Code

Tap-to-receive for the **buy** flow — the receive counterpart to #83's cash-out tap-to-pay. ## The problem A Bolt Card only ever emits its `lnurlw` **withdraw** voucher (a *spend* credential) — the wrong direction to deposit *into* it. So a plain tap can't push sats to the card, and `lnurl-auth` doesn't help either (NTAG424 does AES SUN, not the secp256k1 signing lnurl-auth needs). ## The approach Use the same tap as an **authenticated identity** (`external_id` + the SUN `p`/`c`, verified server-side exactly as `/scan` does) to resolve the card wallet's **lnurlp / Lightning Address**, then have the ATM *pay* an invoice for the payout amount over its existing nostr transport. Settlement reuses the tested cash-in completion path (`payInvoice` → `PAYMENT_RECEIVED`). **Card issuance is unchanged** — same NDEF, same keys, same `external_id`. Receive is a server-side reading of the same tap; already-issued cards work as-is. ## What's in this PR (ATM side — complete, hardware-independent) - **`electron/lnurl-pay.ts`** (new) — `resolveCardInvoice(lnurlw, amountMsat)`: resolve card → pay target → LUD-16/LUD-06 → returns a BOLT11. Accepts three resolver response shapes (Lightning Address / inline `payRequest` / `lnurlp` pointer). `scanUrlToResolver()` is the single **HTTPS-today / Nostr-tomorrow transport seam**. Never throws. **13 tests.** - **IPC `lnurl:pay-card`** (`main.ts`) — runs the HTTPS in the main process to dodge renderer CORS; `preload.ts` + `electron.d.ts` expose `resolveCardInvoice()`. - **`stores/atm.ts`** — `handleBoltCardReceive()`; the single NFC listener now **routes the same physical tap by flow**: cash-out `displayingInvoice` pulls, cash-in `displayingQR` receives. Status/cleanup extended to the QR screen; `simulateBoltCardReceive()` for dev. - **`CashInView.vue`** — NFC status line + dev paste-lnurlw "Tap" input. - **`docs/boltcard-receive-resolver.md`** — spec for the one custom LNbits endpoint this depends on. ## Depends on (LNbits `omni-private`, not in this repo) A custom `GET /boltcards/api/v1/pay/{external_id}?p=&c=` resolver — a `/scan` sibling that verifies the same SUN and returns the card wallet's Lightning Address. Spec in `docs/boltcard-receive-resolver.md`. Until it exists, the tap path is testable via the dev input / mocked fetch; the withdraw-QR cash-in path is unaffected. ## Notes - **Double-payout window:** the withdraw-QR fallback and the tap are either/or; the withdraw link is `uses:1` and the tap gates re-entry + leaves `displayingQR` on success. Documented as acceptable-for-now in the spec. - **Future Nostr:** a nostr-native boltcard would answer the same `external_id + SUN` identity over the ATM's existing nostr connection, dropping the clearnet HTTPS call — localized to `scanUrlToResolver`/the resolve step. ## Verification - `pnpm typecheck` (renderer + electron) clean - Prettier clean - `vitest run electron/` — 55 passed (13 new in `lnurl-pay.test.ts`) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Tap-to-receive for the buy flow, the receive counterpart to #83's
cash-out tap-to-pay. A Bolt Card only emits an lnurlw withdraw voucher
(wrong direction to deposit into it), so the tap is used as an
authenticated identity (external_id + SUN p/c) to resolve the card
wallet's lnurlp/Lightning Address; the ATM then pays an invoice for the
payout over its existing nostr transport.

- electron/lnurl-pay.ts: resolveCardInvoice() — resolve card -> pay
  target -> LUD-16/LUD-06 -> BOLT11. scanUrlToResolver() is the single
  HTTPS-today / nostr-tomorrow transport seam. 13 tests.
- IPC lnurl:pay-card (main-process HTTPS to dodge renderer CORS) +
  preload/electron.d.ts surface.
- stores/atm.ts: handleBoltCardReceive() settles via the existing
  payInvoice -> PAYMENT_RECEIVED path; the one NFC listener now routes
  the same tap by flow (cash-out pulls, cash-in receives).
- CashInView.vue: NFC status + dev tap input.
- docs/boltcard-receive-resolver.md: spec for the custom LNbits
  /boltcards/api/v1/pay/<id> resolver endpoint (omni-private side).

Card issuance is unchanged — same NDEF/keys/external_id; receive is a
server-side reading of the same tap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
padreug deleted branch feat/boltcard-nfc-cashin 2026-08-06 16:33:10 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/bitspire!84
No description provided.