From 64582e7fe65a1775abdca4b15e84bcfa9fb15526 Mon Sep 17 00:00:00 2001
From: Patrick Mulligan
Date: Thu, 6 Aug 2026 00:30:13 +0200
Subject: [PATCH] feat(machine): Bolt Card (NFC) tap-to-receive on cash-in
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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/ 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
---
apps/machine/electron/lnurl-pay.test.ts | 120 +++++++++++++
apps/machine/electron/lnurl-pay.ts | 226 ++++++++++++++++++++++++
apps/machine/electron/main.ts | 23 ++-
apps/machine/electron/preload.ts | 11 ++
apps/machine/src/stores/atm.ts | 86 +++++++--
apps/machine/src/types/electron.d.ts | 5 +
apps/machine/src/views/CashInView.vue | 55 +++++-
docs/boltcard-receive-resolver.md | 110 ++++++++++++
8 files changed, 605 insertions(+), 31 deletions(-)
create mode 100644 apps/machine/electron/lnurl-pay.test.ts
create mode 100644 apps/machine/electron/lnurl-pay.ts
create mode 100644 docs/boltcard-receive-resolver.md
diff --git a/apps/machine/electron/lnurl-pay.test.ts b/apps/machine/electron/lnurl-pay.test.ts
new file mode 100644
index 0000000..165688f
--- /dev/null
+++ b/apps/machine/electron/lnurl-pay.test.ts
@@ -0,0 +1,120 @@
+import { describe, it, expect, vi } from 'vitest'
+import { resolveCardInvoice, scanUrlToResolver, lnAddressToLnurlp } from './lnurl-pay'
+
+const LNURLW =
+ 'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788'
+const BOLT11 = 'lnbc10u1p3xyz...'
+
+/** Mock fetch that returns the given JSON bodies per call, in order. */
+function mockFetch(bodies: unknown[]) {
+ const calls: string[] = []
+ const impl = vi.fn(async (url: string | URL) => {
+ calls.push(url.toString())
+ const body = bodies[calls.length - 1]
+ return { json: async () => body } as Response
+ })
+ return { impl: impl as unknown as typeof fetch, calls }
+}
+
+describe('scanUrlToResolver', () => {
+ it('rewrites /scan/ to /pay/ and preserves p + c', () => {
+ const r = scanUrlToResolver(LNURLW)
+ expect(r).toContain('https://lnbits.l484.com/boltcards/api/v1/pay/abc123')
+ expect(r).toContain('p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF')
+ expect(r).toContain('c=1122334455667788')
+ })
+ it('returns null for a non-scan URL', () => {
+ expect(scanUrlToResolver('lnurlw://host/somethingelse?p=1&c=2')).toBeNull()
+ expect(scanUrlToResolver('http://host/boltcards/api/v1/scan/x')).toBeNull()
+ })
+})
+
+describe('lnAddressToLnurlp', () => {
+ it('maps name@host to the well-known lnurlp URL', () => {
+ expect(lnAddressToLnurlp('cardname@l484.com')).toBe(
+ 'https://l484.com/.well-known/lnurlp/cardname'
+ )
+ })
+ it('rejects non-addresses', () => {
+ expect(lnAddressToLnurlp('not-an-address')).toBeNull()
+ expect(lnAddressToLnurlp('')).toBeNull()
+ })
+})
+
+describe('resolveCardInvoice', () => {
+ const payReq = {
+ tag: 'payRequest',
+ callback: 'https://lnbits.l484.com/lnurlp/api/v1/lnurl/cb',
+ minSendable: 1000,
+ maxSendable: 100_000_000,
+ metadata: '[["text/plain","bolt card top-up"]]',
+ }
+
+ it('resolver returns a payRequest inline → fetches the invoice', async () => {
+ const { impl, calls } = mockFetch([payReq, { pr: BOLT11 }])
+ const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
+ expect(res).toEqual({ ok: true, bolt11: BOLT11 })
+ // 1st call = the /pay resolver; 2nd = the callback with amount in msat.
+ expect(calls[0]).toContain('/boltcards/api/v1/pay/abc123')
+ expect(calls[1]).toContain('amount=21000')
+ })
+
+ it('resolver returns a Lightning Address → LUD-16 → invoice', async () => {
+ const { impl, calls } = mockFetch([
+ { lightningAddress: 'cardname@l484.com' },
+ payReq,
+ { pr: BOLT11 },
+ ])
+ const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
+ expect(res).toEqual({ ok: true, bolt11: BOLT11 })
+ expect(calls[1]).toBe('https://l484.com/.well-known/lnurlp/cardname')
+ expect(calls[2]).toContain('amount=21000')
+ })
+
+ it('rejects a non-lnurlw tag', async () => {
+ const { impl } = mockFetch([])
+ const res = await resolveCardInvoice('http://nope', 21_000, { fetchImpl: impl })
+ expect(res).toMatchObject({ ok: false })
+ expect(res.reason).toMatch(/not a valid Bolt Card/i)
+ })
+
+ it('rejects a zero amount', async () => {
+ const { impl } = mockFetch([])
+ const res = await resolveCardInvoice(LNURLW, 0, { fetchImpl: impl })
+ expect(res).toMatchObject({ ok: false, reason: 'no amount to send' })
+ })
+
+ it('surfaces an ERROR from the resolver (bad SUN)', async () => {
+ const { impl } = mockFetch([{ status: 'ERROR', reason: 'invalid card' }])
+ const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
+ expect(res).toMatchObject({ ok: false, reason: 'invalid card' })
+ })
+
+ it('rejects (without calling the callback) when the amount exceeds maxSendable', async () => {
+ const { impl, calls } = mockFetch([{ ...payReq, maxSendable: 5000 }])
+ const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
+ expect(res).toMatchObject({ ok: false, reason: 'amount is above the card wallet maximum' })
+ expect(calls).toHaveLength(1) // callback never hit
+ })
+
+ it('surfaces an ERROR from the pay callback', async () => {
+ const { impl } = mockFetch([payReq, { status: 'ERROR', reason: 'wallet frozen' }])
+ const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
+ expect(res).toMatchObject({ ok: false, reason: 'wallet frozen' })
+ })
+
+ it('rejects when the card wallet has no receive address', async () => {
+ const { impl } = mockFetch([{ foo: 'bar' }])
+ const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
+ expect(res).toMatchObject({ ok: false, reason: 'card wallet has no receive address' })
+ })
+
+ it('handles a network failure gracefully', async () => {
+ const impl = vi.fn(async () => {
+ throw new Error('ECONNREFUSED')
+ }) as unknown as typeof fetch
+ const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
+ expect(res.ok).toBe(false)
+ expect(res.reason).toMatch(/could not reach the card/i)
+ })
+})
diff --git a/apps/machine/electron/lnurl-pay.ts b/apps/machine/electron/lnurl-pay.ts
new file mode 100644
index 0000000..91da90c
--- /dev/null
+++ b/apps/machine/electron/lnurl-pay.ts
@@ -0,0 +1,226 @@
+/**
+ * LNURL-pay resolver (LUD-06 / LUD-16) — the ATM as the *paying* party.
+ *
+ * Bolt Card tap-to-RECEIVE for the cash-in (buy) flow. A Bolt Card only ever
+ * emits its `lnurlw://…?p=…&c=…` voucher — a *withdraw* (spend) credential — so
+ * we can't push sats into it directly. Instead the tap is used as an
+ * authenticated identity (external_id + SUN p/c) to look up the card wallet's
+ * *pay* target, then the ATM fetches an invoice for the payout amount:
+ * 1. resolveCardPayTarget — GET the boltcards `/pay/?p=&c=` resolver
+ * (a sibling of `/scan`); it verifies the same SUN and returns the card
+ * wallet's Lightning Address / lnurlp (or a LUD-06 payRequest directly).
+ * 2. toPayRequest → LUD-16 (Lightning Address) or LUD-06 fetch → payRequest.
+ * 3. requestInvoice — GET `callback?amount=` → a BOLT11 for the amount.
+ * The returned BOLT11 is handed back to the renderer, which pays it over the
+ * ATM's existing LNbits/nostr transport (stores/atm.ts `payInvoice`), so
+ * settlement + PAYMENT_RECEIVED reuse the tested cash-in completion path.
+ *
+ * Runs in the MAIN process (Node fetch) to avoid renderer CORS, exactly like
+ * lnurl-withdraw.ts.
+ *
+ * Transport seam: `resolveCardPayTarget()` is the single HTTPS-today /
+ * Nostr-tomorrow swap point. The rest is standard LNURL-pay against whatever
+ * pay target it returns and is transport-independent.
+ */
+
+import { lnurlwToHttps } from './lnurl-withdraw.js'
+
+export interface ResolveCardInvoiceResult {
+ ok: boolean
+ /** BOLT11 to pay when ok; the renderer settles it over the nostr transport. */
+ bolt11?: string
+ /** Human-readable reason when ok is false (safe to surface on-screen). */
+ reason?: string
+}
+
+type FetchLike = typeof fetch
+
+export interface ResolveCardInvoiceOptions {
+ /** Injected for tests; defaults to global fetch. */
+ fetchImpl?: FetchLike
+ /** Per-request timeout (default 15s). */
+ timeoutMs?: number
+}
+
+/** Resolver response — any of these shapes is accepted (see the spec doc). */
+interface CardPayTarget {
+ status?: string
+ reason?: string
+ // (a) a LUD-06 payRequest, inline
+ tag?: string
+ callback?: string
+ minSendable?: number
+ maxSendable?: number
+ metadata?: string
+ // (b) a Lightning Address, e.g. "cardname@l484.com"
+ lightningAddress?: string
+ // (c) an lnurlp pointer (https or lnurl://)
+ lnurlp?: string
+ lnurl?: string
+}
+
+/** LUD-06 payRequest (subset) + error shape. */
+interface PayRequest {
+ tag?: string
+ callback?: string
+ minSendable?: number
+ maxSendable?: number
+ metadata?: string
+ status?: string
+ reason?: string
+}
+
+/** LUD-06 second-response (the callback body). */
+interface PayValues {
+ pr?: string
+ status?: string
+ reason?: string
+}
+
+interface Ctx {
+ doFetch: FetchLike
+ timeoutMs: number
+}
+
+function errMsg(e: unknown): string {
+ if (e instanceof Error)
+ return e.name === 'TimeoutError' || e.name === 'AbortError' ? 'timed out' : e.message
+ return String(e)
+}
+
+function appendQuery(url: string, params: Record): string {
+ const u = new URL(url)
+ for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v)
+ return u.toString()
+}
+
+/**
+ * Derive the boltcards *pay* resolver URL from a tapped card's `lnurlw`.
+ * The card presents `…/boltcards/api/v1/scan/?p=&c=` (a withdraw voucher);
+ * the receive resolver is its sibling `…/boltcards/api/v1/pay/?p=&c=`,
+ * carrying the same SUN p/c. This is the HTTPS transport seam — a future
+ * nostr-native card would resolve the same identity over nostr instead.
+ */
+export function scanUrlToResolver(lnurlw: string): string | null {
+ const https = lnurlwToHttps(lnurlw)
+ if (!https) return null
+ const u = new URL(https)
+ if (!u.pathname.includes('/scan/')) return null
+ u.pathname = u.pathname.replace('/scan/', '/pay/')
+ return u.toString()
+}
+
+/** LUD-16: map a Lightning Address `name@host` to its lnurlp URL. */
+export function lnAddressToLnurlp(addr: string): string | null {
+ const m = addr.trim().match(/^([a-z0-9._%+-]+)@([a-z0-9.-]+)$/i)
+ if (!m) return null
+ return `https://${m[2]}/.well-known/lnurlp/${m[1]}`
+}
+
+async function fetchPayRequest(
+ url: string,
+ ctx: Ctx
+): Promise<{ ok: true; payRequest: PayRequest } | { ok: false; reason: string }> {
+ let body: PayRequest
+ try {
+ const res = await ctx.doFetch(url, { signal: AbortSignal.timeout(ctx.timeoutMs) })
+ body = (await res.json()) as PayRequest
+ } catch (e) {
+ return { ok: false, reason: `could not reach the card wallet: ${errMsg(e)}` }
+ }
+ if (body.status === 'ERROR') {
+ return { ok: false, reason: body.reason || 'card wallet rejected the request' }
+ }
+ if (body.tag !== 'payRequest' || !body.callback) {
+ return { ok: false, reason: 'card wallet did not return a pay request' }
+ }
+ return { ok: true, payRequest: body }
+}
+
+/** Turn a resolver response into a LUD-06 payRequest (fetching if needed). */
+async function toPayRequest(
+ target: CardPayTarget,
+ ctx: Ctx
+): Promise<{ ok: true; payRequest: PayRequest } | { ok: false; reason: string }> {
+ // (a) resolver returned a LUD-06 payRequest inline.
+ if (target.tag === 'payRequest' && target.callback) {
+ return { ok: true, payRequest: target }
+ }
+ // (b) resolver returned a Lightning Address (the common case here).
+ if (typeof target.lightningAddress === 'string') {
+ const url = lnAddressToLnurlp(target.lightningAddress)
+ if (!url) return { ok: false, reason: 'card wallet address is invalid' }
+ return fetchPayRequest(url, ctx)
+ }
+ // (c) resolver returned an lnurlp pointer.
+ const pointer = target.lnurlp ?? target.lnurl
+ if (typeof pointer === 'string') {
+ const url = lnurlwToHttps(pointer)
+ if (!url) return { ok: false, reason: 'card wallet lnurlp is invalid' }
+ return fetchPayRequest(url, ctx)
+ }
+ return { ok: false, reason: 'card wallet has no receive address' }
+}
+
+async function requestInvoice(
+ pr: PayRequest,
+ amountMsat: number,
+ ctx: Ctx
+): Promise {
+ if (typeof pr.minSendable === 'number' && amountMsat < pr.minSendable) {
+ return { ok: false, reason: 'amount is below the card wallet minimum' }
+ }
+ if (typeof pr.maxSendable === 'number' && amountMsat > pr.maxSendable) {
+ return { ok: false, reason: 'amount is above the card wallet maximum' }
+ }
+ const cbUrl = appendQuery(pr.callback!, { amount: String(amountMsat) })
+ let vals: PayValues
+ try {
+ const res = await ctx.doFetch(cbUrl, { signal: AbortSignal.timeout(ctx.timeoutMs) })
+ vals = (await res.json()) as PayValues
+ } catch (e) {
+ return { ok: false, reason: `could not fetch the invoice: ${errMsg(e)}` }
+ }
+ if (vals.status === 'ERROR') {
+ return { ok: false, reason: vals.reason || 'card wallet declined' }
+ }
+ if (!vals.pr || !/^ln[a-z0-9]/i.test(vals.pr.trim())) {
+ return { ok: false, reason: 'card wallet returned no invoice' }
+ }
+ return { ok: true, bolt11: vals.pr.trim() }
+}
+
+/**
+ * Resolve a tapped Bolt Card + a payout amount to a BOLT11 the ATM can pay.
+ * Never throws — every failure returns `{ ok: false, reason }`.
+ */
+export async function resolveCardInvoice(
+ lnurlw: string,
+ amountMsat: number,
+ opts: ResolveCardInvoiceOptions = {}
+): Promise {
+ const ctx: Ctx = { doFetch: opts.fetchImpl ?? fetch, timeoutMs: opts.timeoutMs ?? 15_000 }
+
+ const resolverUrl = scanUrlToResolver(lnurlw)
+ if (!resolverUrl) return { ok: false, reason: 'not a valid Bolt Card (lnurlw) tag' }
+ if (!(amountMsat > 0)) return { ok: false, reason: 'no amount to send' }
+
+ // 1) Resolve card → pay target (the transport seam: HTTPS today).
+ let target: CardPayTarget
+ try {
+ const res = await ctx.doFetch(resolverUrl, { signal: AbortSignal.timeout(ctx.timeoutMs) })
+ target = (await res.json()) as CardPayTarget
+ } catch (e) {
+ return { ok: false, reason: `could not reach the card: ${errMsg(e)}` }
+ }
+ if (target.status === 'ERROR') {
+ return { ok: false, reason: target.reason || 'card rejected the tap' }
+ }
+
+ // 2) Normalize to a LUD-06 payRequest.
+ const pr = await toPayRequest(target, ctx)
+ if (!pr.ok) return pr
+
+ // 3) Ask for an invoice for the payout amount.
+ return requestInvoice(pr.payRequest, amountMsat, ctx)
+}
diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts
index 81676cf..6cfcb20 100644
--- a/apps/machine/electron/main.ts
+++ b/apps/machine/electron/main.ts
@@ -43,6 +43,7 @@ import {
} from './state-store.js'
import { initializeHal, type HalInstance } from './hal-service.js'
import { executeLnurlWithdraw } from './lnurl-withdraw.js'
+import { resolveCardInvoice } from './lnurl-pay.js'
import { startNfcReader, type NfcStatus } from './nfc-service.js'
// ESM equivalent of __dirname
@@ -120,9 +121,7 @@ function loadBranding(): BrandingConfig | null {
if (Object.keys(colors).length > 0) customColors = colors
if (dark && typeof dark === 'object') {
const darkColors = Object.fromEntries(
- Object.entries(dark as Record).filter(
- ([, v]) => typeof v === 'string'
- )
+ Object.entries(dark as Record).filter(([, v]) => typeof v === 'string')
) as Record
if (Object.keys(darkColors).length > 0) customColorsDark = darkColors
}
@@ -431,6 +430,20 @@ ipcMain.handle(
}
)
+// Bolt Card cash-in (receive): resolve a tapped card + payout amount to a
+// BOLT11 on the card wallet, which the renderer then pays over the nostr
+// transport (stores/atm.ts payInvoice). HTTPS to the card host runs here in the
+// main process to dodge renderer CORS. See lnurl-pay.ts.
+ipcMain.handle(
+ 'lnurl:pay-card',
+ async (
+ _event,
+ args: { lnurlw: string; amountMsat: number }
+ ): Promise<{ ok: boolean; bolt11?: string; reason?: string }> => {
+ return resolveCardInvoice(args.lnurlw, args.amountMsat)
+ }
+)
+
// State persistence IPC handlers
ipcMain.handle('state:load-cassettes', () => loadCassettes())
ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes))
@@ -447,9 +460,7 @@ ipcMain.handle('state:remediate-transaction', (_event, txid: string, remediatedB
ipcMain.handle('state:get-last-known-config-created-at', (): number =>
getLastKnownConfigCreatedAt()
)
-ipcMain.handle('state:get-bootstrap-published-at', (): number | null =>
- getBootstrapPublishedAt()
-)
+ipcMain.handle('state:get-bootstrap-published-at', (): number | null => getBootstrapPublishedAt())
ipcMain.handle('state:mark-bootstrap-published', (_event, unixTimestamp: number): void => {
markBootstrapPublished(unixTimestamp)
})
diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts
index 8be0ed8..8ced2d5 100644
--- a/apps/machine/electron/preload.ts
+++ b/apps/machine/electron/preload.ts
@@ -134,6 +134,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
amountMsat?: number
}): Promise<{ ok: boolean; reason?: string }> => ipcRenderer.invoke('lnurl:withdraw', args),
+ // Bolt Card cash-in: resolve a tapped card + amount to a BOLT11 to pay.
+ resolveCardInvoice: (args: {
+ lnurlw: string
+ amountMsat: number
+ }): Promise<{ ok: boolean; bolt11?: string; reason?: string }> =>
+ ipcRenderer.invoke('lnurl:pay-card', args),
+
applyOperatorCassettesConfig: (
payload: {
positions: Record
@@ -272,6 +279,10 @@ declare global {
bolt11: string
amountMsat?: number
}) => Promise<{ ok: boolean; reason?: string }>
+ resolveCardInvoice: (args: {
+ lnurlw: string
+ amountMsat: number
+ }) => Promise<{ ok: boolean; bolt11?: string; reason?: string }>
applyOperatorCassettesConfig: (
payload: { positions: Record },
eventCreatedAt: number
diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts
index 06dea22..db1a124 100644
--- a/apps/machine/src/stores/atm.ts
+++ b/apps/machine/src/stores/atm.ts
@@ -11,14 +11,8 @@ import {
} from '@bitSpire/state-machine'
import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
import { classifyInitError } from '@/services/init-error'
-import {
- startOperatorConfigService,
- type OperatorConfigService,
-} from '@/services/operator-config'
-import {
- startOperatorFeesService,
- type OperatorFeesService,
-} from '@/services/operator-fees'
+import { startOperatorConfigService, type OperatorConfigService } from '@/services/operator-config'
+import { startOperatorFeesService, type OperatorFeesService } from '@/services/operator-fees'
import type { HalConfig, HalServices } from '@/services/hal'
import type { MachineModel } from '@/config'
import type { LightningBackend } from '@/services/lightning'
@@ -52,7 +46,8 @@ function computeFeeSats(ctx: ATMContext, isCashIn: boolean): number {
`Unit fraction expected (0.05 = 5%), not a percentage.`
)
}
- const principalSats = ctx.exchangeRate > 0 ? Math.floor((ctx.fiatCents / 100) * ctx.exchangeRate) : 0
+ const principalSats =
+ ctx.exchangeRate > 0 ? Math.floor((ctx.fiatCents / 100) * ctx.exchangeRate) : 0
const feeSats = isCashIn
? principalSats - ctx.satsAmount // cash-in: customer receives less than principal
: ctx.satsAmount - principalSats // cash-out: customer pays more than principal
@@ -567,9 +562,13 @@ export const useAtmStore = defineStore('atm', () => {
}
}
- // Clear Bolt Card state whenever we leave the invoice screen (dispensed,
- // timed out, or cancelled) so a stale "processing"/error can't linger.
- if (currentNested !== 'displayingInvoice' && prevNestedState === 'displayingInvoice') {
+ // Clear Bolt Card state whenever we leave a tap screen — the cash-out
+ // invoice ('displayingInvoice') or the cash-in QR ('displayingQR') — so a
+ // stale "processing"/error can't linger into the next flow.
+ const leftBoltCardScreen =
+ (prevNestedState === 'displayingInvoice' && currentNested !== 'displayingInvoice') ||
+ (prevNestedState === 'displayingQR' && currentNested !== 'displayingQR')
+ if (leftBoltCardScreen) {
boltCardProcessing.value = false
nfcStatus.value = null
}
@@ -614,26 +613,76 @@ export const useAtmStore = defineStore('atm', () => {
}
}
+ /**
+ * A tapped Bolt Card during the cash-in QR screen: RECEIVE sats to the card.
+ * The card's lnurlw is only a spend voucher, so we resolve it to the card
+ * wallet's lnurlp (main process), fetch an invoice for the payout, and pay it
+ * over the nostr transport via the normal `payInvoice` → PAYMENT_RECEIVED
+ * path. Settlement + completion reuse the tested cash-in flow.
+ */
+ async function handleBoltCardReceive(lnurlw: string) {
+ if (!(isCashIn.value && nestedState.value === 'displayingQR')) return
+ const amountSats = context.value?.satsAmount ?? 0
+ if (amountSats <= 0) return
+ if (boltCardProcessing.value) return // one at a time
+ boltCardProcessing.value = true
+ nfcStatus.value = { state: 'processing', message: 'Reading card…' }
+ try {
+ const amountMsat = amountSats * 1000
+ const res = await window.electronAPI!.resolveCardInvoice({ lnurlw, amountMsat })
+ if (!res.ok || !res.bolt11) {
+ boltCardProcessing.value = false
+ nfcStatus.value = { state: 'declined', message: res.reason ?? 'Card could not receive' }
+ return
+ }
+ nfcStatus.value = { state: 'accepted', message: 'Card found — sending sats…' }
+ const paid = await payInvoice(res.bolt11)
+ if (!paid) {
+ boltCardProcessing.value = false
+ nfcStatus.value = { state: 'error', message: paymentError.value ?? 'Payment failed' }
+ }
+ // On success payInvoice fires PAYMENT_RECEIVED; state leaves displayingQR
+ // and the subscribe-cleanup above resets nfcStatus/boltCardProcessing.
+ } catch (e) {
+ console.warn('[ATM] Bolt Card receive failed:', e)
+ boltCardProcessing.value = false
+ nfcStatus.value = { state: 'error', message: 'Card payment failed' }
+ }
+ }
+
/** Wire the main-process reader once (idempotent via preload removeAllListeners). */
function setupNfcListener() {
if (!isElectron || !window.electronAPI?.onNfcCardTapped) return
window.electronAPI.onNfcCardTapped((lnurlw) => {
- void handleBoltCardTap(lnurlw)
+ // Route the same physical tap by flow: cash-out pulls, cash-in receives.
+ if (isCashOut.value && nestedState.value === 'displayingInvoice') {
+ void handleBoltCardTap(lnurlw)
+ } else if (isCashIn.value && nestedState.value === 'displayingQR') {
+ void handleBoltCardReceive(lnurlw)
+ }
})
window.electronAPI.onNfcStatus?.((status) => {
- // Only surface reader status on the invoice screen, and don't clobber an
- // in-flight pull's message.
- if (nestedState.value === 'displayingInvoice' && !boltCardProcessing.value) {
+ // Only surface reader status on a tap screen, and don't clobber an
+ // in-flight tap's message.
+ const onTapScreen =
+ (isCashOut.value && nestedState.value === 'displayingInvoice') ||
+ (isCashIn.value && nestedState.value === 'displayingQR')
+ if (onTapScreen && !boltCardProcessing.value) {
nfcStatus.value = status
}
})
}
- /** Dev/mock: simulate a tap with a pasted lnurlw (test without a card). */
+ /** Dev/mock: simulate a cash-out tap with a pasted lnurlw (test without a card). */
function simulateBoltCardTap(lnurlw: string) {
void handleBoltCardTap(lnurlw)
}
+ /** Dev/mock: simulate a cash-in (receive) tap with a pasted lnurlw. */
+ function simulateBoltCardReceive(lnurlw: string) {
+ void handleBoltCardReceive(lnurlw)
+ }
+
/**
* Group an array of inserted bill denominations into { denomination, count } pairs.
*/
@@ -1586,10 +1635,11 @@ export const useAtmStore = defineStore('atm', () => {
isCashOut,
nestedState,
- // Bolt Card cash-out (NFC)
+ // Bolt Card (NFC): cash-out pulls, cash-in receives
nfcStatus,
boltCardProcessing,
simulateBoltCardTap,
+ simulateBoltCardReceive,
// Actions
initialize,
diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts
index d391944..0e5846d 100644
--- a/apps/machine/src/types/electron.d.ts
+++ b/apps/machine/src/types/electron.d.ts
@@ -109,6 +109,11 @@ declare global {
bolt11: string
amountMsat?: number
}) => Promise<{ ok: boolean; reason?: string }>
+ /** Bolt Card cash-in: resolve a tapped card + amount to a BOLT11 to pay. */
+ resolveCardInvoice: (args: {
+ lnurlw: string
+ amountMsat: number
+ }) => Promise<{ ok: boolean; bolt11?: string; reason?: string }>
applyOperatorCassettesConfig: (
payload: { positions: Record },
eventCreatedAt: number
diff --git a/apps/machine/src/views/CashInView.vue b/apps/machine/src/views/CashInView.vue
index 5210c86..915c113 100644
--- a/apps/machine/src/views/CashInView.vue
+++ b/apps/machine/src/views/CashInView.vue
@@ -48,6 +48,9 @@ const showCancelButton = computed(() => {
// Invoice input for manual payment
const invoiceInput = ref('')
+// Dev: paste an lnurlw to simulate a Bolt Card tap-to-receive
+const mockLnurlw = ref('')
+
// Copy state for ndebit URI
const copied = ref(false)
@@ -279,7 +282,9 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
Done Inserting
@@ -331,10 +336,31 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
{{ atmStore.fiatSymbol }}{{ ((context?.fiatCents || 0) / 100).toFixed(2) }}
-
-
-
-
Waiting for wallet scan...
+
+
+
+
+
+ {{
+ atmStore.boltCardProcessing
+ ? 'Processing card…'
+ : isElectron
+ ? 'Tap your card or scan to receive'
+ : 'Waiting for wallet scan...'
+ }}
+
+
+
+ {{ atmStore.nfcStatus.message }}
+
@@ -353,8 +379,8 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
-
-
diff --git a/docs/boltcard-receive-resolver.md b/docs/boltcard-receive-resolver.md
new file mode 100644
index 0000000..d505d3c
--- /dev/null
+++ b/docs/boltcard-receive-resolver.md
@@ -0,0 +1,110 @@
+# Bolt Card tap-to-receive — LNbits resolver endpoint
+
+Spec for the small **custom endpoint** the ATM needs on the LNbits `boltcards`
+extension to support **tap-to-receive** (the cash-in / buy flow). The ATM side
+(`apps/machine/electron/lnurl-pay.ts`) is already built against this contract;
+this document is what to implement in the `omni-private` LNbits fork.
+
+## Why a new endpoint
+
+A Bolt Card only ever emits its `lnurlw://…/scan/?p=&c=` voucher —
+a **withdraw** (spend) credential. You cannot push sats _into_ the card with it.
+To deposit to the card's wallet, the ATM uses the same tap as an **authenticated
+identity** (the `external_id` + the SUN `p`/`c`, verified exactly as `/scan`
+does) and needs the wallet's **pay** target back. Stock `boltcards` is
+withdraw-only, so we add a `pay` sibling of `scan`.
+
+The card is **not re-written** — same NDEF, same keys, same `external_id`. Only
+the server learns a new way to answer the same tap.
+
+## Endpoint
+
+```
+GET /boltcards/api/v1/pay/{external_id}?p={p}&c={c}
+```
+
+- Same URL shape as `GET /boltcards/api/v1/scan/{external_id}?p=&c=`, with the
+ path segment `scan` → `pay`. The ATM derives it by string-substitution on the
+ tapped `lnurlw` (`scanUrlToResolver()` in `lnurl-pay.ts`).
+- **Verify `p`/`c` exactly like `/scan`**: decrypt the PICC (`p`) with the
+ card's `k1`, recompute the CMAC (`c`) with `k2`, check the read counter is
+ fresh (monotonic). Reject replays. Reuse the boltcards SUN verification path —
+ do not fork it. A valid `p`/`c` is the authorization: it proves card
+ possession and prevents a cloned UID from misdirecting a deposit.
+- No auth key/header — like `/scan`, this is a public LNURL-style endpoint
+ gated solely by the SUN.
+
+## Response
+
+Return **one** of the following JSON shapes (the ATM accepts all three). Since
+each card wallet has a Lightning Address, either of the first two is simplest.
+
+### (a) Lightning Address (recommended)
+
+```json
+{ "lightningAddress": "cardname@l484.com" }
+```
+
+The ATM resolves it via LUD-16 (`/.well-known/lnurlp/cardname`) → LUD-06 pay.
+
+### (b) LUD-06 payRequest, inline
+
+```json
+{
+ "tag": "payRequest",
+ "callback": "https://lnbits.l484.com/lnurlp/api/v1/lnurl/",
+ "minSendable": 1000,
+ "maxSendable": 100000000,
+ "metadata": "[[\"text/plain\",\"bolt card top-up\"]]"
+}
+```
+
+Hand back the card wallet's existing `lnurlp` payRequest directly (no extra
+round-trip for the ATM).
+
+### (c) lnurlp pointer
+
+```json
+{ "lnurlp": "https://lnbits.l484.com/lnurlp/" }
+```
+
+An `https://` (or `lnurl://`) URL the ATM will fetch to get the payRequest.
+
+### Error
+
+```json
+{ "status": "ERROR", "reason": "invalid card" }
+```
+
+Use for a failed SUN check, a disabled/unknown card, or a wallet with no pay
+target. `reason` is surfaced verbatim on the ATM screen, so keep it terse and
+non-sensitive.
+
+## Flow, end to end
+
+```
+customer inserts cash → ATM owes N sats → customer taps Bolt Card
+ → ATM reads lnurlw (external_id + fresh p/c)
+ → GET /boltcards/api/v1/pay/?p=&c= ← THIS ENDPOINT
+ → { lightningAddress | payRequest | lnurlp }
+ → ATM: LUD-16/LUD-06 → GET callback?amount= → BOLT11
+ → ATM pays the BOLT11 over its own nostr transport → card wallet credited
+ → PAYMENT_RECEIVED → cash-in completes
+```
+
+Amounts are in **millisatoshis** on the LUD-06 callback (`amount=`), per
+spec. Make sure each card wallet's `minSendable`/`maxSendable` span the ATM's
+payout range or the tap will be declined with "amount is above/below the card
+wallet …".
+
+## Notes
+
+- **Double-payout:** the cash-in screen still shows the LNURL-withdraw QR as a
+ fallback (customer _pulls_). A tap _pays_ instead. The ATM gates re-entry
+ while a tap is in flight and leaves `displayingQR` on success; the withdraw
+ link is `uses:1`. A customer would have to both tap and pull near-simultaneously
+ to double-collect — acceptable for now, revisit if it bites.
+- **Future nostr transport:** `resolveCardPayTarget` (the `/pay` GET) is the one
+ HTTPS-today / nostr-tomorrow seam. A nostr-native boltcard would answer the
+ same `external_id + SUN` identity over the ATM's existing nostr connection,
+ dropping the clearnet HTTPS call. The rest (standard LNURL-pay) is unchanged.